├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── hsreplay ├── __init__.py ├── document.py ├── dumper.py ├── elements.py ├── stream.py └── utils.py ├── scripts ├── annotate.py └── convert.py ├── setup.cfg ├── setup.py ├── test_lossless_loading.py ├── tests ├── __init__.py ├── conftest.py ├── test_main.py └── test_stream.py └── tox.ini /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.203.0/containers/python-3/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster 4 | ARG VARIANT="3.8-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} 6 | 7 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 8 | ARG NODE_VERSION="none" 9 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 10 | 11 | # [Optional] If your pip requirements rarely change, uncomment this section to add them to the image. 12 | # COPY requirements.txt /tmp/pip-tmp/ 13 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 14 | # && rm -rf /tmp/pip-tmp 15 | 16 | # [Optional] Uncomment this section to install additional OS packages. 17 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 18 | # && apt-get -y install --no-install-recommends 19 | 20 | # [Optional] Uncomment this line to install global node packages. 21 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 22 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.203.0/containers/python-3 3 | { 4 | "name": "Python 3", 5 | "runArgs": ["--init"], 6 | "build": { 7 | "dockerfile": "Dockerfile", 8 | "context": "..", 9 | "args": { 10 | // Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6 11 | // Append -bullseye or -buster to pin to an OS version. 12 | // Use -bullseye variants on local on arm64/Apple Silicon. 13 | "VARIANT": "3.8-bullseye", 14 | // Options 15 | "NODE_VERSION": "none" 16 | } 17 | }, 18 | 19 | // Set *default* container specific settings.json values on container create. 20 | "settings": { 21 | "python.pythonPath": "/usr/local/bin/python", 22 | "python.languageServer": "Pylance", 23 | "python.linting.enabled": true, 24 | "python.linting.pylintEnabled": false, 25 | "python.linting.flake8Enabled": true, 26 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 27 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 28 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 29 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 30 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 31 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 32 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 33 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 34 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint" 35 | }, 36 | 37 | // Add the IDs of extensions you want installed when the container is created. 38 | "extensions": [ 39 | "ms-python.python", 40 | "ms-python.vscode-pylance" 41 | ], 42 | 43 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 44 | // "forwardPorts": [], 45 | 46 | // Use 'postCreateCommand' to run commands after the container is created. 47 | "postCreateCommand": "pip3 install --user --editable . && pip3 install --user pytest==5.0.1", 48 | 49 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 50 | "remoteUser": "vscode" 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 3.9 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: 3.9 15 | - name: Install dependencies 16 | run: | 17 | pip install --upgrade pip setuptools types-setuptools wheel 18 | pip install tox 19 | - name: Run tox 20 | run: tox 21 | release: 22 | name: Release 23 | needs: [test] 24 | runs-on: ubuntu-latest 25 | permissions: 26 | # required to authenticate for the PyPi upload below 27 | id-token: write 28 | if: startsWith(github.ref, 'refs/tags') 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Set up Python 3.9 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: 3.9 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install wheel 39 | - name: Build 40 | run: python setup.py sdist bdist_wheel 41 | - name: Upload to pypi 42 | uses: pypa/gh-action-pypi-publish@release/v1 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .venv/ 3 | .mypy_cache/ 4 | *.egg-info/ 5 | .pytest_cache/ 6 | __pycache__/ 7 | /tests/logdata/ 8 | .vscode/* 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jerome Leclanche 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-hsreplay 2 | 3 | [![Build Status](https://travis-ci.com/HearthSim/python-hsreplay.svg?branch=master)](https://travis-ci.com/HearthSim/python-hsreplay) 4 | [![PyPI](https://img.shields.io/pypi/v/hsreplay.svg)](https://pypi.org/project/hsreplay/) 5 | 6 | A python module for HSReplay support. 7 | 8 | 9 | 10 | 11 | ## Installation 12 | 13 | The library is available on PyPI. `pip install hsreplay` will install it. 14 | 15 | Dependencies: 16 | 17 | * [`hearthstone`](https://github.com/HearthSim/python-hearthstone) 18 | * [`hslog`](https://github.com/HearthSim/python-hslog) 19 | * `lxml` (optional) for faster XML parsing and writing. Will use `xml.etree` if not available. 20 | * `aniso8601` or `dateutil` for timestamp parsing 21 | 22 | 23 | ## Usage 24 | 25 | The main document class is `hsreplay.document.HSReplayDocument`. 26 | That class contains all the necessary functionality to import and export HSReplay files. 27 | 28 | 29 | ### Reading/Writing HSReplay XML files 30 | 31 | The classmethod `from_xml_file(fp)` takes a file-like object and will return a document. 32 | If you already have an `ElementTree` object, you can call the `from_xml(xml)` classmethod instead. 33 | 34 | To export to an HSReplay XML document, the `HSReplayDocument.toxml(pretty=False)` method can be 35 | used to obtain a UTF8-encoded string containing the document. 36 | 37 | 38 | ### Reading directly from a log file 39 | 40 | The library integrates directly with the `python-hearthstone` library to produce `HSReplayDocument` 41 | objects directly from a log file or a parser instance. 42 | 43 | Use the helper classmethods `from_log_file(fp, processor="GameState", date=None, build=None)` and 44 | `from_parser(parser, build=None)`, respectively. 45 | 46 | 47 | ### Exporting back to a PacketTree 48 | 49 | It is possible to export HSReplayDocument objects back into a PacketTree with the `to_packet_tree()` 50 | method. This therefore allows lossless conversion from a PacketTree, into HSReplayDocument, then 51 | back into a PacketTree. 52 | 53 | This is especially interesting because of the native functionality in `python-hearthstone` which is 54 | able to export to a Game tree and allows exploring the game state. By converting HSReplayDocument 55 | objects to a PacketTree, it's very easy to follow the replay at a gameplay level, explore the state 56 | of the various entities and even hook into the exporter in order to programmatically analyze it. 57 | -------------------------------------------------------------------------------- /hsreplay/__init__.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | 3 | __version__ = pkg_resources.require("hsreplay")[0].version 4 | 5 | DTD_VERSION = "1.7" 6 | SYSTEM_DTD = "https://hearthsim.info/hsreplay/dtd/hsreplay-%s.dtd" % (DTD_VERSION) 7 | -------------------------------------------------------------------------------- /hsreplay/document.py: -------------------------------------------------------------------------------- 1 | from . import DTD_VERSION 2 | from .dumper import game_to_xml, parse_log 3 | from .elements import GameNode 4 | from .utils import ElementTree, toxml 5 | 6 | 7 | class HSReplayDocument: 8 | ROOT_NAME = "HSReplay" 9 | 10 | @classmethod 11 | def from_log_file(cls, fp, processor="GameState", date=None, build=None): 12 | parser = parse_log(fp, processor, date) 13 | return cls.from_parser(parser, build) 14 | 15 | @classmethod 16 | def from_packet_tree( 17 | cls, packet_tree, build=None, player_manager=None, game_meta=None 18 | ): 19 | ret = cls(build) 20 | ret._update_document() 21 | for tree in packet_tree: 22 | game = game_to_xml( 23 | tree, 24 | player_manager=player_manager, 25 | game_meta=game_meta, 26 | ) 27 | ret.games.append(game) 28 | return ret 29 | 30 | @classmethod 31 | def from_parser(cls, parser, build=None, game_meta=None): 32 | return cls.from_packet_tree( 33 | parser.games, 34 | build, 35 | game_meta=game_meta, 36 | player_manager=parser.player_manager 37 | ) 38 | 39 | @classmethod 40 | def from_xml_file(cls, fp): 41 | xml = ElementTree.parse(fp) 42 | return cls.from_xml(xml) 43 | 44 | @classmethod 45 | def from_xml(cls, xml): 46 | root = xml.getroot() 47 | build = root.attrib.get("build") 48 | ret = cls(build) 49 | ret.version = root.attrib.get("version") 50 | for game in xml.findall("Game"): 51 | gamenode = GameNode.from_xml(game) 52 | ret.games.append(gamenode) 53 | 54 | return ret 55 | 56 | def __init__(self, build=None): 57 | self.build = build 58 | self.version = DTD_VERSION 59 | self.games = [] 60 | self.root = None 61 | 62 | def _create_document(self): 63 | builder = ElementTree.TreeBuilder() 64 | attrs = {"version": self.version} 65 | if self.build is not None: 66 | attrs["build"] = str(self.build) 67 | builder.start(self.ROOT_NAME, attrs) 68 | builder.end(self.ROOT_NAME) 69 | 70 | self.root = builder.close() 71 | return self.root 72 | 73 | def _update_document(self): 74 | self._create_document() 75 | for game in self.games: 76 | self.root.append(game.xml()) 77 | 78 | def to_packet_tree(self): 79 | self._update_document() 80 | ret = [] 81 | for game in self.games: 82 | ret.append(game.export()) 83 | return ret 84 | 85 | def to_xml(self, pretty=False): 86 | self._update_document() 87 | return toxml(self.root, pretty=pretty) 88 | -------------------------------------------------------------------------------- /hsreplay/dumper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | from hearthstone.enums import MetaDataType 5 | from hslog import LogParser 6 | from hslog.packets import (Block, CachedTagForDormantChange, ChangeEntity, 7 | Choices, ChosenEntities, CreateGame, FullEntity, 8 | HideEntity, MetaData, Options, ResetGame, 9 | SendChoices, SendOption, ShowEntity, ShuffleDeck, 10 | SubSpell, TagChange, VOSpell) 11 | from hslog.player import coerce_to_entity_id, PlayerManager 12 | 13 | from . import elements 14 | from .utils import set_game_meta_on_game 15 | 16 | 17 | def serialize_entity(entity): 18 | if entity: 19 | return coerce_to_entity_id(entity) 20 | 21 | 22 | def add_initial_tags(ts, packet, packet_element): 23 | for tag, value in packet.tags: 24 | tag_element = elements.TagNode(ts, tag, value) 25 | packet_element.append(tag_element) 26 | 27 | 28 | def add_choices(ts, packet, packet_element): 29 | for i, entity_id in enumerate(packet.choices): 30 | choice_element = elements.ChoiceNode(ts, i, entity_id) 31 | packet_element.append(choice_element) 32 | 33 | 34 | def add_options(ts, packet, packet_element): 35 | for i, option in enumerate(packet.options): 36 | if option.optype == "option": 37 | cls = elements.OptionNode 38 | elif option.optype == "target": 39 | cls = elements.OptionTargetNode 40 | elif option.optype == "subOption": 41 | cls = elements.SubOptionNode 42 | else: 43 | raise NotImplementedError("Unhandled option type: %r" % (option.optype)) 44 | try: 45 | entity = serialize_entity(option.entity) 46 | except RuntimeError: 47 | # This is a hack to ensure we can serialize games from Hearthstone 18336. 48 | # Real names are shoved in the options, not used anywhere else... 49 | entity = None 50 | option_element = cls(ts, i, entity, option.error, option.error_param, option.type) 51 | add_options(ts, option, option_element) 52 | packet_element.append(option_element) 53 | 54 | 55 | def add_packets_recursive(packets, entity_element): 56 | for packet in packets: 57 | if hasattr(packet, "entity"): 58 | _ent = serialize_entity(packet.entity) 59 | ts = packet.ts 60 | 61 | if isinstance(packet, CreateGame): 62 | packet_element = elements.GameEntityNode(ts, _ent) 63 | add_initial_tags(ts, packet, packet_element) 64 | entity_element.append(packet_element) 65 | for player in packet.players: 66 | entity_id = serialize_entity(player.entity) 67 | player_element = elements.PlayerNode( 68 | ts, entity_id, player.player_id, 69 | player.hi, player.lo, player.name 70 | ) 71 | entity_element.append(player_element) 72 | add_initial_tags(ts, player, player_element) 73 | continue 74 | elif isinstance(packet, Block): 75 | effect_index = int(packet.effectindex or 0) 76 | packet_element = elements.BlockNode( 77 | ts, _ent, packet.type, 78 | packet.index if packet.index != -1 else None, 79 | packet.effectid or None, 80 | effect_index if effect_index != -1 else None, 81 | serialize_entity(packet.target), 82 | packet.suboption if packet.suboption != -1 else None, 83 | packet.trigger_keyword if packet.trigger_keyword else None 84 | ) 85 | add_packets_recursive(packet.packets, packet_element) 86 | elif isinstance(packet, MetaData): 87 | # With verbose=false, we always have 0 packet.info :( 88 | if len(packet.info) not in (0, packet.count): 89 | logging.warning("META_DATA count is %r for %r", packet.count, packet.info) 90 | 91 | if packet.meta == MetaDataType.JOUST: 92 | data = serialize_entity(packet.data) 93 | else: 94 | data = packet.data 95 | packet_element = elements.MetaDataNode( 96 | ts, packet.meta, data, packet.count 97 | ) 98 | for i, info in enumerate(packet.info): 99 | e = elements.MetaDataInfoNode(packet.ts, i, info) 100 | packet_element.append(e) 101 | elif isinstance(packet, TagChange): 102 | packet_element = elements.TagChangeNode( 103 | packet.ts, _ent, packet.tag, packet.value, 104 | packet.has_change_def if packet.has_change_def else None 105 | ) 106 | elif isinstance(packet, HideEntity): 107 | packet_element = elements.HideEntityNode(ts, _ent, packet.zone) 108 | elif isinstance(packet, ShowEntity): 109 | packet_element = elements.ShowEntityNode(ts, _ent, packet.card_id) 110 | add_initial_tags(ts, packet, packet_element) 111 | elif isinstance(packet, FullEntity): 112 | packet_element = elements.FullEntityNode(ts, _ent, packet.card_id) 113 | add_initial_tags(ts, packet, packet_element) 114 | elif isinstance(packet, ChangeEntity): 115 | packet_element = elements.ChangeEntityNode(ts, _ent, packet.card_id) 116 | add_initial_tags(ts, packet, packet_element) 117 | elif isinstance(packet, Choices): 118 | packet_element = elements.ChoicesNode( 119 | ts, _ent, packet.id, packet.tasklist, packet.type, 120 | packet.min, packet.max, serialize_entity(packet.source) 121 | ) 122 | add_choices(ts, packet, packet_element) 123 | elif isinstance(packet, SendChoices): 124 | packet_element = elements.SendChoicesNode(ts, packet.id, packet.type) 125 | add_choices(ts, packet, packet_element) 126 | elif isinstance(packet, ChosenEntities): 127 | packet_element = elements.ChosenEntitiesNode(ts, _ent, packet.id) 128 | add_choices(ts, packet, packet_element) 129 | elif isinstance(packet, Options): 130 | packet_element = elements.OptionsNode(ts, packet.id) 131 | add_options(ts, packet, packet_element) 132 | elif isinstance(packet, SendOption): 133 | packet_element = elements.SendOptionNode( 134 | ts, packet.option, packet.suboption, packet.target, packet.position 135 | ) 136 | elif isinstance(packet, ResetGame): 137 | packet_element = elements.ResetGameNode(ts) 138 | elif isinstance(packet, SubSpell): 139 | packet_element = elements.SubSpellNode( 140 | ts, packet.spell_prefab_guid, packet.source, packet.target_count 141 | ) 142 | for i, target in enumerate(packet.targets): 143 | e = elements.SubSpellTargetNode(ts, i, target) 144 | packet_element.append(e) 145 | add_packets_recursive(packet.packets, packet_element) 146 | elif isinstance(packet, CachedTagForDormantChange): 147 | packet_element = elements.CachedTagForDormantChangeNode( 148 | packet.ts, _ent, packet.tag, packet.value 149 | ) 150 | elif isinstance(packet, VOSpell): 151 | packet_element = elements.VOSpellNode( 152 | packet.ts, packet.brguid, packet.vospguid, packet.blocking, packet.delayms 153 | ) 154 | elif isinstance(packet, ShuffleDeck): 155 | packet_element = elements.ShuffleDeckNode(packet.ts, packet.player_id) 156 | else: 157 | raise NotImplementedError(repr(packet)) 158 | entity_element.append(packet_element) 159 | 160 | 161 | def parse_log(fp, processor, date): 162 | parser = LogParser() 163 | parser._game_state_processor = processor 164 | parser._current_date = date 165 | parser.read(fp) 166 | 167 | return parser 168 | 169 | 170 | def game_to_xml( 171 | tree, 172 | game_meta=None, 173 | player_manager: Optional[PlayerManager] = None, 174 | player_meta=None, 175 | decks=None 176 | ): 177 | # game_tree = tree.export() 178 | game_element = elements.GameNode(tree.ts) 179 | add_packets_recursive(tree.packets, game_element) 180 | players = game_element.players 181 | 182 | if game_meta is not None: 183 | set_game_meta_on_game(game_meta, game_element) 184 | 185 | if player_meta is not None: 186 | for player, meta in zip(players, player_meta): 187 | player._attributes = meta 188 | 189 | if decks is not None: 190 | for player, deck in zip(players, decks): 191 | player.deck = deck 192 | 193 | if player_manager: 194 | # Set the player names 195 | for player in players: 196 | if not player.name: 197 | player.name = player_manager.get_player_by_entity_id(player.id).name 198 | 199 | return game_element 200 | -------------------------------------------------------------------------------- /hsreplay/elements.py: -------------------------------------------------------------------------------- 1 | from hslog import packets 2 | from hslog.player import PlayerReference 3 | 4 | from .utils import ElementTree, parse_datetime 5 | 6 | 7 | def node_for_tagname(tag): 8 | for k, v in globals().items(): 9 | if k.endswith("Node") and v.tagname == tag: 10 | return v 11 | raise ValueError("No matching node for tag %r" % (tag)) 12 | 13 | 14 | class Node: 15 | attributes = () 16 | tagname = None 17 | 18 | def __init__(self, *args): 19 | self._attributes = {} 20 | self.nodes = [] 21 | for k, arg in zip(("ts", ) + self.attributes, args): 22 | setattr(self, k, arg) 23 | 24 | def __repr__(self): 25 | return "<%s>" % self.__class__.__name__ 26 | 27 | @classmethod 28 | def from_xml(cls, xml): 29 | if xml.tag != cls.tagname: 30 | raise ValueError("%s.from_xml() called with %r, not %r" % ( 31 | cls.__name__, xml.tag, cls.tagname 32 | )) 33 | ts = xml.attrib.get("ts") 34 | if ts: 35 | ts = parse_datetime(ts) 36 | ret = cls(ts) 37 | for element in xml: 38 | ecls = node_for_tagname(element.tag) 39 | node = ecls.from_xml(element) 40 | for attrname in ecls.attributes: 41 | setattr(node, attrname, element.attrib.get(attrname)) 42 | ret.nodes.append(node) 43 | return ret 44 | 45 | def append(self, node): 46 | self.nodes.append(node) 47 | 48 | def xml(self): 49 | element = ElementTree.Element(self.tagname) 50 | for node in self.nodes: 51 | element.append(node.xml()) 52 | for attr in self.attributes: 53 | attrib = getattr(self, attr, None) 54 | if attrib is not None: 55 | if isinstance(attrib, bool): 56 | attrib = str(attrib).lower() 57 | elif isinstance(attrib, int): 58 | # Check for enums 59 | attrib = str(int(attrib)) 60 | elif isinstance(attrib, PlayerReference): 61 | attrib = str(attrib.entity_id) 62 | element.attrib[attr] = attrib 63 | if self.timestamp and self.ts: 64 | element.attrib["ts"] = self.ts.isoformat() 65 | 66 | for k, v in self._attributes.items(): 67 | element.attrib[k] = v 68 | 69 | return element 70 | 71 | def to_xml_string(self): 72 | return ElementTree.tostring(self.xml()).decode("utf-8") 73 | 74 | 75 | class GameNode(Node): 76 | tagname = "Game" 77 | attributes = ("id", "type", "format", "scenarioID", "reconnecting") 78 | timestamp = True 79 | packet_class = packets.PacketTree 80 | 81 | @property 82 | def players(self): 83 | return self.nodes[1:3] 84 | 85 | def export(self): 86 | tree = self.packet_class(self.ts) 87 | create_game = self.nodes[0].export() 88 | 89 | for player in self.players: 90 | create_game.players.append(player.export()) 91 | tree.packets.append(create_game) 92 | 93 | for node in self.nodes[3:]: 94 | tree.packets.append(node.export()) 95 | return tree 96 | 97 | 98 | class GameEntityNode(Node): 99 | tagname = "GameEntity" 100 | attributes = ("id", ) 101 | timestamp = False 102 | packet_class = packets.CreateGame 103 | 104 | def export(self): 105 | packet = self.packet_class(self.ts, int(self.id)) 106 | for node in self.nodes: 107 | packet.tags.append(node.export()) 108 | return packet 109 | 110 | 111 | class PlayerNode(Node): 112 | tagname = "Player" 113 | attributes = ( 114 | "id", "playerID", "accountHi", "accountLo", "name", 115 | "rank", "legendRank", "cardback" 116 | ) 117 | timestamp = False 118 | packet_class = packets.CreateGame.Player 119 | 120 | def export(self): 121 | packet = self.packet_class( 122 | self.ts, int(self.id), int(self.playerID), 123 | int(self.accountHi), int(self.accountLo) 124 | ) 125 | packet.name = self.name 126 | for node in self.nodes: 127 | if node.tagname == "Tag": 128 | packet.tags.append(node.export()) 129 | return packet 130 | 131 | def xml(self): 132 | ret = super().xml() 133 | deck = getattr(self, "deck", None) 134 | if deck is not None: 135 | element = ElementTree.Element("Deck") 136 | ret.append(element) 137 | for card in deck: 138 | e = ElementTree.Element("Card") 139 | e.attrib["id"] = card 140 | element.append(e) 141 | 142 | return ret 143 | 144 | 145 | class DeckNode(Node): 146 | tagname = "Deck" 147 | attributes = () 148 | timestamp = False 149 | packet_class = None 150 | 151 | 152 | class CardNode(Node): 153 | tagname = "Card" 154 | attributes = ("id", "premium") 155 | timestamp = False 156 | packet_class = None 157 | 158 | 159 | class FullEntityNode(Node): 160 | tagname = "FullEntity" 161 | attributes = ("id", "cardID") 162 | timestamp = False 163 | packet_class = packets.FullEntity 164 | 165 | def export(self): 166 | packet = self.packet_class(self.ts, int(self.id), self.cardID) 167 | for node in self.nodes: 168 | packet.tags.append(node.export()) 169 | return packet 170 | 171 | 172 | class ShowEntityNode(Node): 173 | tagname = "ShowEntity" 174 | attributes = ("entity", "cardID") 175 | timestamp = False 176 | packet_class = packets.ShowEntity 177 | 178 | def export(self): 179 | packet = self.packet_class(self.ts, int(self.entity or 0), self.cardID) 180 | for node in self.nodes: 181 | packet.tags.append(node.export()) 182 | return packet 183 | 184 | 185 | class BlockNode(Node): 186 | tagname = "Block" 187 | attributes = ( 188 | "entity", "type", "index", "effectCardId", "effectIndex", 189 | "target", "subOption", "triggerKeyword" 190 | ) 191 | timestamp = True 192 | packet_class = packets.Block 193 | 194 | def export(self): 195 | index = int(self.index) if self.index is not None else -1 196 | effectIndex = int(self.effectIndex) if self.effectIndex is not None else -1 197 | packet = self.packet_class( 198 | self.ts, int(self.entity or 0), int(self.type), index, 199 | self.effectCardId, effectIndex, int(self.target or 0), 200 | int(self.subOption) if self.subOption else None, int(self.triggerKeyword or 0) 201 | ) 202 | for node in self.nodes: 203 | packet.packets.append(node.export()) 204 | packet.ended = True 205 | return packet 206 | 207 | 208 | class MetaDataNode(Node): 209 | tagname = "MetaData" 210 | attributes = ("meta", "data", "infoCount") 211 | timestamp = False 212 | packet_class = packets.MetaData 213 | 214 | def export(self): 215 | packet = self.packet_class( 216 | self.ts, int(self.meta), int(self.data or 0), int(self.infoCount or 0) 217 | ) 218 | for node in self.nodes: 219 | packet.info.append(node.export()) 220 | return packet 221 | 222 | 223 | class MetaDataInfoNode(Node): 224 | tagname = "Info" 225 | attributes = ("index", "entity") 226 | timestamp = False 227 | 228 | def export(self): 229 | return int(self.entity or 0) 230 | 231 | 232 | class TagNode(Node): 233 | tagname = "Tag" 234 | attributes = ("tag", "value") 235 | timestamp = False 236 | 237 | def export(self): 238 | return (int(self.tag), int(self.value)) 239 | 240 | 241 | class TagChangeNode(Node): 242 | tagname = "TagChange" 243 | attributes = ("entity", "tag", "value", "hasChangeDef") 244 | timestamp = False 245 | packet_class = packets.TagChange 246 | 247 | def export(self): 248 | return self.packet_class( 249 | self.ts, int(self.entity or 0), int(self.tag), int(self.value), 250 | self.has_change_def 251 | ) 252 | 253 | @property 254 | def has_change_def(self): 255 | if not self.hasChangeDef: 256 | return False 257 | return True if str(self.hasChangeDef).lower() == "true" else False 258 | 259 | 260 | class HideEntityNode(Node): 261 | tagname = "HideEntity" 262 | attributes = ("entity", "zone") 263 | timestamp = True 264 | packet_class = packets.HideEntity 265 | 266 | def export(self): 267 | return self.packet_class(self.ts, int(self.entity or 0), int(self.zone)) 268 | 269 | 270 | class ChangeEntityNode(Node): 271 | tagname = "ChangeEntity" 272 | attributes = ("entity", "cardID") 273 | timestamp = True 274 | packet_class = packets.ChangeEntity 275 | 276 | def export(self): 277 | packet = self.packet_class(self.ts, int(self.entity or 0), self.cardID) 278 | for node in self.nodes: 279 | packet.tags.append(node.export()) 280 | return packet 281 | 282 | 283 | ## 284 | # Choices 285 | 286 | class ChoicesNode(Node): 287 | tagname = "Choices" 288 | attributes = ("entity", "id", "taskList", "type", "min", "max", "source") 289 | timestamp = True 290 | packet_class = packets.Choices 291 | 292 | def export(self): 293 | taskList = int(self.taskList) if self.taskList else None 294 | packet = self.packet_class( 295 | self.ts, int(self.entity or 0), int(self.id), taskList, 296 | int(self.type), int(self.min), int(self.max) 297 | ) 298 | packet.source = self.source 299 | for node in self.nodes: 300 | packet.choices.append(node.export()) 301 | return packet 302 | 303 | 304 | class ChoiceNode(Node): 305 | tagname = "Choice" 306 | attributes = ("index", "entity") 307 | timestamp = False 308 | 309 | def export(self): 310 | return int(self.entity or 0) 311 | 312 | 313 | class ChosenEntitiesNode(Node): 314 | tagname = "ChosenEntities" 315 | attributes = ("entity", "id") 316 | timestamp = True 317 | packet_class = packets.ChosenEntities 318 | 319 | def export(self): 320 | packet = self.packet_class(self.ts, int(self.entity or 0), int(self.id)) 321 | for node in self.nodes: 322 | packet.choices.append(node.export()) 323 | return packet 324 | 325 | 326 | class SendChoicesNode(Node): 327 | tagname = "SendChoices" 328 | attributes = ("id", "type") 329 | timestamp = True 330 | packet_class = packets.SendChoices 331 | 332 | def export(self): 333 | packet = self.packet_class(self.ts, int(self.id), int(self.type)) 334 | for node in self.nodes: 335 | packet.choices.append(node.export()) 336 | return packet 337 | 338 | 339 | ## 340 | # Options 341 | 342 | class OptionsNode(Node): 343 | tagname = "Options" 344 | attributes = ("id", ) 345 | timestamp = True 346 | packet_class = packets.Options 347 | 348 | def export(self): 349 | packet = self.packet_class(self.ts, int(self.id)) 350 | for i, node in enumerate(self.nodes): 351 | packet.options.append(node.export(i)) 352 | return packet 353 | 354 | 355 | class OptionNode(Node): 356 | tagname = "Option" 357 | attributes = ("index", "entity", "error", "errorParam", "type") 358 | timestamp = False 359 | packet_class = packets.Option 360 | 361 | def export(self, id): 362 | optype = "option" 363 | packet = self.packet_class( 364 | self.ts, int(self.entity or 0), id, int(self.type), optype, 365 | self.error, self.errorParam 366 | ) 367 | for i, node in enumerate(self.nodes): 368 | packet.options.append(node.export(i)) 369 | return packet 370 | 371 | 372 | class SubOptionNode(Node): 373 | tagname = "SubOption" 374 | attributes = ("index", "entity", "error", "errorParam") 375 | timestamp = False 376 | packet_class = packets.Option 377 | 378 | def export(self, id): 379 | optype = "subOption" 380 | type = None 381 | packet = self.packet_class( 382 | self.ts, int(self.entity or 0), id, type, optype, 383 | self.error, self.errorParam 384 | ) 385 | for i, node in enumerate(self.nodes): 386 | packet.options.append(node.export(i)) 387 | return packet 388 | 389 | 390 | class OptionTargetNode(Node): 391 | tagname = "Target" 392 | attributes = ("index", "entity", "error", "errorParam") 393 | timestamp = False 394 | packet_class = packets.Option 395 | 396 | def export(self, id): 397 | optype = "target" 398 | type = None 399 | packet = self.packet_class( 400 | self.ts, int(self.entity or 0), id, type, optype, 401 | self.error, self.errorParam 402 | ) 403 | return packet 404 | 405 | 406 | class SendOptionNode(Node): 407 | tagname = "SendOption" 408 | attributes = ("option", "subOption", "target", "position") 409 | timestamp = True 410 | packet_class = packets.SendOption 411 | 412 | def export(self): 413 | return self.packet_class( 414 | self.ts, int(self.option), int(self.subOption), int(self.target), int(self.position) 415 | ) 416 | 417 | 418 | class ResetGameNode(Node): 419 | tagname = "ResetGame" 420 | attributes = () 421 | timestamp = True 422 | packet_class = packets.ResetGame 423 | 424 | def export(self): 425 | return self.packet_class(self.ts) 426 | 427 | 428 | class SubSpellNode(Node): 429 | tagname = "SubSpell" 430 | attributes = ("spellPrefabGuid", "source", "targetCount") 431 | timestamp = True 432 | packet_class = packets.SubSpell 433 | 434 | def export(self): 435 | packet = self.packet_class( 436 | self.ts, self.spellPrefabGuid, 437 | int(self.source) if self.source is not None else None, 438 | int(self.targetCount) 439 | ) 440 | for node in self.nodes: 441 | if isinstance(node, SubSpellTargetNode): 442 | packet.targets.append(node.entity) 443 | continue 444 | packet.packets.append(node.export()) 445 | packet.ended = True 446 | return packet 447 | 448 | 449 | class SubSpellTargetNode(Node): 450 | tagname = "SubSpellTarget" 451 | attributes = ("index", "entity") 452 | timestamp = False 453 | 454 | # SubSpellTargetNode is virtual and cannot be exported 455 | export = None 456 | 457 | 458 | class CachedTagForDormantChangeNode(Node): 459 | tagname = "CachedTagForDormantChange" 460 | attributes = ("entity", "tag", "value") 461 | timestamp = False 462 | packet_class = packets.CachedTagForDormantChange 463 | 464 | def export(self): 465 | return self.packet_class( 466 | self.ts, int(self.entity or 0), int(self.tag), int(self.value) 467 | ) 468 | 469 | 470 | class VOSpellNode(Node): 471 | tagname = "VOSpell" 472 | attributes = ("brass_ring_guid", "vo_spell_prefab_guid", "blocking", "additional_delay_ms") 473 | timestamp = False 474 | packet_class = packets.VOSpell 475 | 476 | def export(self): 477 | return self.packet_class( 478 | self.ts, 479 | self.brass_ring_guid, 480 | self.vo_spell_prefab_guid, 481 | self.blocking == "True", 482 | int(self.additional_delay_ms) 483 | ) 484 | 485 | 486 | class ShuffleDeckNode(Node): 487 | tagname = "ShuffleDeck" 488 | attributes = ("player_id",) 489 | timestamp = False 490 | packet_class = packets.ShuffleDeck 491 | 492 | def export(self): 493 | return self.packet_class(self.ts, int(self.player_id)) 494 | -------------------------------------------------------------------------------- /hsreplay/stream.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from contextlib import contextmanager 3 | from typing import Dict, Optional 4 | 5 | from hearthstone.enums import MetaDataType 6 | from hslog.packets import (Block, CachedTagForDormantChange, ChangeEntity, 7 | Choices, ChosenEntities, CreateGame, FullEntity, 8 | HideEntity, MetaData, Options, ResetGame, 9 | SendChoices, SendOption, ShowEntity, ShuffleDeck, 10 | SubSpell, TagChange, VOSpell) 11 | from hslog.player import PlayerManager, PlayerReference 12 | 13 | from . import elements 14 | from .dumper import serialize_entity 15 | from .utils import set_game_meta_on_game 16 | 17 | 18 | @contextmanager 19 | def element_context(xf, elt: elements.Node, indent: int = 0): 20 | attributes = {} 21 | for attr in elt.attributes: 22 | attrib = getattr(elt, attr, None) 23 | if attrib is not None: 24 | if isinstance(attrib, bool): 25 | attrib = str(attrib).lower() 26 | elif isinstance(attrib, int): 27 | # Check for enums 28 | attrib = str(int(attrib)) 29 | elif isinstance(attrib, PlayerReference): 30 | attrib = str(attrib.entity_id) 31 | attributes[attr] = attrib 32 | if elt.timestamp and elt.ts: 33 | attributes["ts"] = elt.ts.isoformat() 34 | 35 | for k, v in elt._attributes.items(): 36 | attributes[k] = v 37 | 38 | xf.write(" " * indent) 39 | 40 | with xf.element(elt.tagname, attrib=attributes): 41 | xf.write("\n") 42 | yield 43 | xf.write(" " * indent) 44 | 45 | xf.write("\n") 46 | 47 | 48 | def write_element(xf, elt: elements.Node, indent: int = 0): 49 | xf.write(" " * indent) 50 | xf.write(elt.xml()) 51 | xf.write("\n") 52 | 53 | 54 | def write_initial_tags(xf, ts, packet, indent: int = 0): 55 | for tag, value in packet.tags: 56 | write_element(xf, elements.TagNode(ts, tag, value), indent=indent) 57 | 58 | 59 | def write_choices(xf, ts, packet, indent: int = 0): 60 | for i, entity_id in enumerate(packet.choices): 61 | write_element(xf, elements.ChoiceNode(ts, i, entity_id), indent=indent) 62 | 63 | 64 | def write_options(xf, ts, packet, indent: int = 0): 65 | for i, option in enumerate(packet.options): 66 | if option.optype == "option": 67 | cls = elements.OptionNode 68 | elif option.optype == "target": 69 | cls = elements.OptionTargetNode 70 | elif option.optype == "subOption": 71 | cls = elements.SubOptionNode 72 | else: 73 | raise NotImplementedError("Unhandled option type: %r" % option.optype) 74 | try: 75 | entity = serialize_entity(option.entity) 76 | except RuntimeError: 77 | # This is a hack to ensure we can serialize games from Hearthstone 18336. 78 | # Real names are shoved in the options, not used anywhere else... 79 | entity = None 80 | option_element = cls(ts, i, entity, option.error, option.error_param, option.type) 81 | 82 | if option.options: 83 | with element_context(xf, option_element, indent=indent): 84 | 85 | # Handle suboptions 86 | 87 | write_options(xf, ts, option, indent=indent + 2) 88 | else: 89 | write_element(xf, option_element, indent=indent) 90 | 91 | 92 | def write_deck(xf, ts, deck, indent: int = 0): 93 | with element_context(xf, elements.DeckNode(ts), indent=indent): 94 | for card in deck: 95 | write_element(xf, elements.CardNode(ts, card), indent=indent + 2) 96 | 97 | 98 | def write_player( 99 | xf, 100 | ts, 101 | player, 102 | indent: int = 0, 103 | player_manager: Optional[PlayerManager] = None, 104 | player_meta: Optional[Dict] = None 105 | ): 106 | entity_id = serialize_entity(player.entity) 107 | player_element = elements.PlayerNode( 108 | ts, entity_id, player.player_id, player.hi, player.lo, player.name 109 | ) 110 | 111 | this_player_meta = {} 112 | if player_meta and player.player_id in player_meta: 113 | this_player_meta = player_meta.pop(player.player_id) 114 | 115 | if this_player_meta.get("cardback") is not None: 116 | player_element._attributes["cardback"] = str(this_player_meta["cardback"]) 117 | if this_player_meta.get("legendRank") is not None: 118 | player_element._attributes["legendRank"] = str(this_player_meta["legend_rank"]) 119 | if this_player_meta.get("rank") is not None: 120 | player_element._attributes["rank"] = str(this_player_meta["rank"]) 121 | 122 | if player_manager: 123 | if not hasattr(player_element, "name"): 124 | player_record = player_manager.get_player_by_entity_id(entity_id) 125 | player_element._attributes["name"] = player_record.name 126 | 127 | with element_context(xf, player_element, indent=indent): 128 | if this_player_meta.get("deck") is not None: 129 | write_deck(xf, ts, this_player_meta["deck"], indent=indent + 2) 130 | write_initial_tags(xf, ts, player, indent=indent + 2) 131 | 132 | 133 | def write_packets_recursive( 134 | xf, 135 | packets, 136 | indent: int = 0, 137 | player_manager: Optional[PlayerManager] = None, 138 | player_meta: Optional[Dict] = None 139 | ): 140 | for packet in packets: 141 | if hasattr(packet, "entity"): 142 | _ent = serialize_entity(packet.entity) 143 | ts = packet.ts 144 | 145 | if isinstance(packet, CreateGame): 146 | game_element = elements.GameEntityNode(ts, _ent) 147 | with element_context(xf, game_element, indent=indent): 148 | write_initial_tags(xf, ts, packet, indent=indent + 2) 149 | 150 | for player in packet.players: 151 | write_player( 152 | xf, 153 | ts, 154 | player, 155 | indent=indent, 156 | player_manager=player_manager, 157 | player_meta=player_meta 158 | ) 159 | continue 160 | elif isinstance(packet, Block): 161 | effect_index = int(packet.effectindex or 0) 162 | packet_element = elements.BlockNode( 163 | ts, _ent, packet.type, 164 | packet.index if packet.index != -1 else None, 165 | packet.effectid or None, 166 | effect_index if effect_index != -1 else None, 167 | serialize_entity(packet.target), 168 | packet.suboption if packet.suboption != -1 else None, 169 | packet.trigger_keyword if packet.trigger_keyword else None 170 | ) 171 | if packet.packets: 172 | with element_context(xf, packet_element, indent=indent): 173 | write_packets_recursive( 174 | xf, 175 | packet.packets, 176 | indent=indent + 2, 177 | player_manager=player_manager, 178 | player_meta=player_meta 179 | ) 180 | else: 181 | write_element(xf, packet_element, indent=indent) 182 | elif isinstance(packet, MetaData): 183 | # With verbose=false, we always have 0 packet.info :( 184 | if len(packet.info) not in (0, packet.count): 185 | logging.warning("META_DATA count is %r for %r", packet.count, packet.info) 186 | 187 | if packet.meta == MetaDataType.JOUST: 188 | data = serialize_entity(packet.data) 189 | else: 190 | data = packet.data 191 | 192 | metadata_element = elements.MetaDataNode(ts, packet.meta, data, packet.count) 193 | if packet.info: 194 | with element_context(xf, metadata_element, indent=indent): 195 | for i, info in enumerate(packet.info): 196 | write_element( 197 | xf, 198 | elements.MetaDataInfoNode(packet.ts, i, info), 199 | indent=indent + 2 200 | ) 201 | else: 202 | write_element(xf, metadata_element, indent=indent) 203 | elif isinstance(packet, TagChange): 204 | write_element( 205 | xf, 206 | elements.TagChangeNode( 207 | packet.ts, _ent, packet.tag, packet.value, 208 | packet.has_change_def if packet.has_change_def else None 209 | ), 210 | indent=indent 211 | ) 212 | elif isinstance(packet, HideEntity): 213 | write_element(xf, elements.HideEntityNode(ts, _ent, packet.zone), indent=indent) 214 | elif isinstance(packet, ShowEntity): 215 | with element_context( 216 | xf, 217 | elements.ShowEntityNode(ts, _ent, packet.card_id), 218 | indent=indent 219 | ): 220 | write_initial_tags(xf, ts, packet, indent=indent + 2) 221 | elif isinstance(packet, FullEntity): 222 | with element_context( 223 | xf, 224 | elements.FullEntityNode(ts, _ent, packet.card_id), 225 | indent=indent 226 | ): 227 | write_initial_tags(xf, ts, packet, indent=indent + 2) 228 | elif isinstance(packet, ChangeEntity): 229 | with element_context( 230 | xf, 231 | elements.ChangeEntityNode(ts, _ent, packet.card_id), 232 | indent=indent 233 | ): 234 | write_initial_tags(xf, ts, packet, indent=indent + 2) 235 | elif isinstance(packet, Choices): 236 | with element_context( 237 | xf, 238 | elements.ChoicesNode( 239 | ts, _ent, packet.id, packet.tasklist, packet.type, 240 | packet.min, packet.max, serialize_entity(packet.source) 241 | ), 242 | indent=indent 243 | ): 244 | write_choices(xf, ts, packet, indent=indent + 2) 245 | elif isinstance(packet, SendChoices): 246 | with element_context( 247 | xf, 248 | elements.SendChoicesNode(ts, packet.id, packet.type), 249 | indent=indent 250 | ): 251 | write_choices(xf, ts, packet, indent=indent + 2) 252 | elif isinstance(packet, ChosenEntities): 253 | with element_context( 254 | xf, 255 | elements.ChosenEntitiesNode(ts, _ent, packet.id), 256 | indent=indent 257 | ): 258 | write_choices(xf, ts, packet, indent=indent + 2) 259 | elif isinstance(packet, Options): 260 | with element_context(xf, elements.OptionsNode(ts, packet.id), indent=indent): 261 | write_options(xf, ts, packet, indent=indent + 2) 262 | elif isinstance(packet, SendOption): 263 | write_element( 264 | xf, 265 | elements.SendOptionNode( 266 | ts, packet.option, packet.suboption, packet.target, packet.position 267 | ), 268 | indent=indent 269 | ) 270 | elif isinstance(packet, ResetGame): 271 | write_element(xf, elements.ResetGameNode(ts), indent=indent) 272 | elif isinstance(packet, SubSpell): 273 | subspell_elements = elements.SubSpellNode( 274 | ts, packet.spell_prefab_guid, packet.source, packet.target_count 275 | ) 276 | if packet.packets or packet.targets: 277 | with element_context(xf, subspell_elements, indent=indent): 278 | for i, target in enumerate(packet.targets): 279 | write_element( 280 | xf, 281 | elements.SubSpellTargetNode(ts, i, target), 282 | indent=indent + 2 283 | ) 284 | write_packets_recursive( 285 | xf, 286 | packet.packets, 287 | indent=indent + 2, 288 | player_manager=player_manager, 289 | player_meta=player_meta, 290 | ) 291 | else: 292 | write_element(xf, subspell_elements, indent=indent) 293 | elif isinstance(packet, CachedTagForDormantChange): 294 | write_element( 295 | xf, 296 | elements.CachedTagForDormantChangeNode( 297 | packet.ts, _ent, packet.tag, packet.value 298 | ), 299 | indent=indent 300 | ) 301 | elif isinstance(packet, VOSpell): 302 | write_element( 303 | xf, 304 | elements.VOSpellNode( 305 | packet.ts, 306 | packet.brguid, 307 | packet.vospguid, 308 | packet.blocking, 309 | packet.delayms 310 | ), 311 | indent=indent 312 | ) 313 | elif isinstance(packet, ShuffleDeck): 314 | write_element( 315 | xf, 316 | elements.ShuffleDeckNode(packet.ts, packet.player_id), 317 | indent=indent 318 | ) 319 | else: 320 | raise NotImplementedError(repr(packet)) 321 | 322 | 323 | def game_to_xml_stream( 324 | tree, 325 | xf, 326 | game_meta: Optional[Dict] = None, 327 | player_manager: Optional[PlayerManager] = None, 328 | player_meta: Optional[Dict] = None, 329 | indent: int = 0, 330 | ): 331 | game_element = elements.GameNode(tree.ts) 332 | 333 | if game_meta is not None: 334 | set_game_meta_on_game(game_meta, game_element) 335 | 336 | with element_context(xf, game_element, indent=indent): 337 | write_packets_recursive( 338 | xf, 339 | tree.packets, 340 | player_manager=player_manager, 341 | player_meta=player_meta, 342 | indent=indent + 2 343 | ) 344 | -------------------------------------------------------------------------------- /hsreplay/utils.py: -------------------------------------------------------------------------------- 1 | try: 2 | from lxml import etree as ElementTree 3 | LXML = True 4 | except ImportError: 5 | from xml.etree import ElementTree 6 | LXML = False 7 | try: 8 | from aniso8601 import parse_datetime 9 | except ImportError: 10 | from dateutil.parser import parse as parse_datetime 11 | 12 | import enum 13 | from xml.dom import minidom 14 | 15 | from . import SYSTEM_DTD 16 | 17 | __all__ = [ 18 | "ElementTree", "annotate_replay", "parse_datetime", "pretty_xml", "toxml" 19 | ] 20 | 21 | 22 | def toxml(root, pretty): 23 | if LXML: 24 | kwargs = { 25 | "doctype": '' % (SYSTEM_DTD), 26 | "pretty_print": pretty, 27 | "xml_declaration": True, 28 | "encoding": "utf-8", 29 | } 30 | xml = ElementTree.tostring(root, **kwargs) 31 | return xml.decode("utf-8") 32 | 33 | if pretty: 34 | return pretty_xml(root) 35 | return ElementTree.tostring(root).decode("utf-8") 36 | 37 | 38 | def pretty_xml(root): 39 | xml = ElementTree.tostring(root) 40 | ret = minidom.parseString(xml) 41 | 42 | imp = minidom.DOMImplementation() 43 | doctype = imp.createDocumentType( 44 | qualifiedName="hsreplay", 45 | publicId="", 46 | systemId=SYSTEM_DTD, 47 | ) 48 | doc = imp.createDocument(None, "HSReplay", doctype) 49 | for element in list(ret.documentElement.childNodes): 50 | doc.documentElement.appendChild(element) 51 | for k, v in root.attrib.items(): 52 | doc.documentElement.setAttribute(k, v) 53 | 54 | ret = doc.toprettyxml(indent="\t") 55 | return "\n".join(line for line in ret.split("\n") if line.strip()) 56 | 57 | 58 | def _to_string(tag): 59 | result = "<%s" % tag.tag 60 | if len(tag.attrib): 61 | result += " " 62 | result += " ".join("%s=%s" % item for item in tag.attrib.items()) 63 | result += ">" 64 | return result 65 | 66 | 67 | class ResolvedString(str): 68 | is_resolved = True 69 | 70 | 71 | def _get_card_name(db, card_id): 72 | if hasattr(card_id, "is_resolved") and card_id.is_resolved: 73 | return card_id 74 | if card_id not in db: 75 | return "Unknown card %s" % card_id 76 | return db[card_id].name 77 | 78 | 79 | def annotate_replay(infile, outfile): 80 | from hearthstone import cardxml 81 | from hearthstone.enums import ( 82 | TAG_TYPES, BlockType, GameTag, MetaDataType, PlayState, State 83 | ) 84 | db, _ = cardxml.load() 85 | entities = {} 86 | 87 | entity_ref_tags = { 88 | GameTag.LAST_AFFECTED_BY, 89 | GameTag.LAST_CARD_PLAYED, 90 | GameTag.PROPOSED_ATTACKER, 91 | GameTag.PROPOSED_DEFENDER, 92 | GameTag.WEAPON, 93 | } 94 | 95 | tree = ElementTree.parse(infile) 96 | root = tree.getroot() 97 | 98 | for tag in root.iter("FullEntity"): 99 | if "cardID" in tag.attrib: 100 | entities[tag.attrib["id"]] = tag.attrib["cardID"] 101 | 102 | for tag in root.iter("GameEntity"): 103 | entities[tag.attrib["id"]] = ResolvedString("GameEntity") 104 | 105 | for tag in root.iter("Player"): 106 | if "name" in tag.attrib: 107 | entities[tag.attrib["id"]] = ResolvedString(tag.attrib["name"]) 108 | 109 | for tag in root.iter("ShowEntity"): 110 | if "cardID" in tag.attrib: 111 | entities[tag.attrib["entity"]] = tag.attrib["cardID"] 112 | tag.set("EntityName", _get_card_name(db, tag.attrib["cardID"])) 113 | 114 | for tag in root.iter("FullEntity"): 115 | if tag.attrib["id"] in entities: 116 | tag.set("EntityName", _get_card_name(db, entities[tag.attrib["id"]])) 117 | 118 | block_counter = 1 119 | for tag in root.iter("Block"): 120 | tag.set("block_sequence_num", str(block_counter)) 121 | block_counter += 1 122 | if "entity" in tag.attrib and tag.attrib["entity"] in entities: 123 | tag.set("EntityCardID", entities[tag.attrib["entity"]]) 124 | if tag.attrib["entity"] == "1": 125 | tag.set("EntityCardName", "GameEntity") 126 | elif tag.attrib["entity"] in ("2", "3"): 127 | tag.set("EntityCardName", entities[tag.attrib["entity"]]) 128 | else: 129 | tag.set("EntityCardName", _get_card_name(db, entities[tag.attrib["entity"]])) 130 | 131 | if "target" in tag.attrib and tag.attrib["target"] in entities: 132 | tag.set("TargetName", _get_card_name(db, entities[tag.attrib["target"]])) 133 | 134 | if "triggerKeyword" in tag.attrib: 135 | try: 136 | tag.set("TriggerKeywordName", GameTag(int(tag.attrib["triggerKeyword"])).name) 137 | except ValueError: 138 | pass 139 | 140 | for tag in root.iter("Tag"): 141 | try: 142 | tag_enum = GameTag(int(tag.attrib["tag"])) 143 | tag.set("GameTagName", tag_enum.name) 144 | 145 | enum_or_type = TAG_TYPES.get(tag_enum) 146 | if enum_or_type: 147 | if enum_or_type.__class__ == enum.EnumMeta: 148 | tag.set( 149 | "%sName" % (enum_or_type.__name__), 150 | enum_or_type(int(tag.attrib["value"])).name 151 | ) 152 | except ValueError: 153 | pass 154 | 155 | for tag_change in root.iter("TagChange"): 156 | if "entity" in tag_change.attrib and tag_change.attrib["entity"] in entities: 157 | tag_change.set("EntityCardID", entities[tag_change.attrib["entity"]]) 158 | tag_change.set("EntityCardName", _get_card_name(db, entities[tag_change.attrib["entity"]])) 159 | 160 | if int(tag_change.attrib["tag"]) in entity_ref_tags and tag_change.attrib["value"] in entities: 161 | tag_change.set("ValueReferenceCardID", entities[tag_change.attrib["value"]]) 162 | tag_change.set( 163 | "ValueReferenceCardName", 164 | _get_card_name(db, entities[tag_change.attrib["value"]]) 165 | ) 166 | 167 | if tag_change.attrib["tag"] == str(GameTag.STATE.value): 168 | tag_change.set("StateName", State(int(tag_change.attrib["value"])).name) 169 | 170 | if tag_change.attrib["tag"] == str(GameTag.PLAYSTATE.value): 171 | tag_change.set("PlayStateName", PlayState(int(tag_change.attrib["value"])).name) 172 | 173 | try: 174 | tag_enum = GameTag(int(tag_change.attrib["tag"])) 175 | tag_change.set("GameTagName", tag_enum.name) 176 | 177 | enum_or_type = TAG_TYPES.get(tag_enum) 178 | if enum_or_type: 179 | if enum_or_type.__class__ == enum.EnumMeta: 180 | tag_change.set( 181 | "%sName" % (enum_or_type.__name__), 182 | enum_or_type(int(tag_change.attrib["value"])).name 183 | ) 184 | except ValueError: 185 | pass 186 | 187 | for block in root.iter("Block"): 188 | try: 189 | tag_enum = BlockType(int(block.attrib["type"])) 190 | except ValueError: 191 | tag_enum = block.attrib["type"] 192 | 193 | block.set("BlockTypeName", tag_enum.name) 194 | 195 | for option in root.iter("Option"): 196 | if "entity" in option.attrib and option.attrib["entity"] in entities: 197 | option.set("EntityCardID", entities[option.attrib["entity"]]) 198 | option.set("EntityName", _get_card_name(db, entities[option.attrib["entity"]])) 199 | 200 | for target in root.iter("Target"): 201 | if "entity" in target.attrib and target.attrib["entity"] in entities: 202 | target.set("EntityCardID", entities[target.attrib["entity"]]) 203 | target.set("EntityName", _get_card_name(db, entities[target.attrib["entity"]])) 204 | 205 | for choices in root.iter("Choices"): 206 | if "entity" in choices.attrib and choices.attrib["entity"] in entities: 207 | choices.set("EntityCardID", entities[choices.attrib["entity"]]) 208 | choices.set("EntityName", _get_card_name(db, entities[choices.attrib["entity"]])) 209 | if "source" in choices.attrib and choices.attrib["source"] in entities: 210 | choices.set("SourceCardID", entities[choices.attrib["source"]]) 211 | choices.set("SourceName", _get_card_name(db, entities[choices.attrib["source"]])) 212 | 213 | for choice in root.iter("Choice"): 214 | if "entity" in choice.attrib and choice.attrib["entity"] in entities: 215 | choice.set("EntityCardID", entities[choice.attrib["entity"]]) 216 | choice.set("EntityName", _get_card_name(db, entities[choice.attrib["entity"]])) 217 | 218 | for meta in root.iter("MetaData"): 219 | if "meta" in meta.attrib: 220 | meta.set("MetaName", MetaDataType(int(meta.attrib["meta"])).name) 221 | 222 | for target in root.iter("Info"): 223 | if "entity" in target.attrib and target.attrib["entity"] in entities: 224 | target.set("EntityName", _get_card_name(db, entities[target.attrib["entity"]])) 225 | 226 | tree.write(outfile, pretty_print=True) 227 | 228 | 229 | def set_game_meta_on_game(game_meta, game_element): 230 | if game_meta is None: 231 | return 232 | 233 | if game_meta.get("id") is not None: 234 | game_element._attributes["id"] = str(game_meta["id"]) 235 | if game_meta.get("format") is not None: 236 | game_element._attributes["format"] = str(game_meta["format"]) 237 | if game_meta.get("hs_game_type") is not None: 238 | game_element._attributes["type"] = str(game_meta["hs_game_type"]) 239 | if game_meta.get("scenario_id") is not None: 240 | game_element._attributes["scenarioID"] = str(game_meta["scenario_id"]) 241 | 242 | if "reconnecting" in game_meta: 243 | game_element._attributes["reconnecting"] = str(game_meta["reconnecting"]) 244 | -------------------------------------------------------------------------------- /scripts/annotate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | A command line tool for annotating .hsreplay files with GameTag data to 4 | facilitate development activities. 5 | """ 6 | import os 7 | import sys 8 | from argparse import ArgumentParser, ArgumentTypeError, FileType 9 | from datetime import datetime 10 | 11 | from hsreplay.utils import annotate_replay 12 | 13 | 14 | def date_arg(s): 15 | try: 16 | return datetime.strptime(s, "%Y-%m-%d") 17 | except ValueError as e: 18 | raise ArgumentTypeError(e) 19 | 20 | 21 | def main(): 22 | parser = ArgumentParser(description=__doc__) 23 | parser.add_argument( 24 | "infile", nargs="?", type=FileType("r"), default=sys.stdin, 25 | help="the input replay data" 26 | ) 27 | 28 | args = parser.parse_args() 29 | with os.fdopen(sys.stdout.fileno(), "wb", closefd=False) as outfile: 30 | annotate_replay(args.infile, outfile) 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /scripts/convert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import io 3 | import sys 4 | from argparse import ArgumentParser, ArgumentTypeError 5 | from datetime import datetime 6 | 7 | from hslog.filter import BattlegroundsLogFilter 8 | from hsreplay.document import HSReplayDocument 9 | from hsreplay.utils import annotate_replay 10 | 11 | 12 | def date_arg(s): 13 | try: 14 | return datetime.strptime(s, "%Y-%m-%d") 15 | except ValueError as e: 16 | raise ArgumentTypeError(e) 17 | 18 | 19 | def main(): 20 | parser = ArgumentParser() 21 | parser.add_argument("files", nargs="*") 22 | parser.add_argument("--processor", dest="processor", default="GameState") 23 | parser.add_argument("--battlegrounds-filter", action="store_true") 24 | parser.add_argument("--annotate", action="store_true") 25 | parser.add_argument("--default-date", dest="date", type=date_arg, help="Format: YYYY-MM-DD") 26 | # https://stackoverflow.com/questions/9226516/ 27 | args = parser.parse_args(sys.argv[1:]) 28 | for filename in args.files: 29 | with open(filename) as f: 30 | if args.battlegrounds_filter: 31 | f = BattlegroundsLogFilter(f) 32 | doc = HSReplayDocument.from_log_file(f, args.processor, args.date) 33 | xml = doc.to_xml() 34 | if args.annotate: 35 | out = io.BytesIO() 36 | annotate_replay(io.BytesIO(xml.encode("utf-8")), out) 37 | xml = out.getvalue().decode("utf-8") 38 | 39 | print(xml) 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = hsreplay 3 | version = 1.16.1 4 | description = A library for creating and parsing HSReplay files 5 | author = Jerome Leclanche 6 | author_email = jerome@hearthsim.net 7 | url = https://github.com/HearthSim/python-hsreplay/ 8 | download_url = https://github.com/HearthSim/python-hsreplay/tarball/master 9 | classifiers = 10 | Development Status :: 5 - Production/Stable 11 | Intended Audience :: Developers 12 | License :: OSI Approved :: MIT License 13 | Programming Language :: Python 14 | Programming Language :: Python :: 3 15 | Programming Language :: Python :: 3.6 16 | Topic :: Games/Entertainment 17 | 18 | [options] 19 | packages = find: 20 | install_requires = 21 | aniso8601 22 | hearthstone 23 | hslog >= 1.15.1 24 | lxml 25 | 26 | [options.packages.find] 27 | exclude = tests 28 | 29 | [bdist_wheel] 30 | universal = 1 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup 3 | 4 | setup() 5 | -------------------------------------------------------------------------------- /test_lossless_loading.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from argparse import ArgumentParser 4 | from datetime import datetime 5 | from io import BytesIO 6 | 7 | from hsreplay.document import HSReplayDocument 8 | 9 | BUILD = 12345 10 | 11 | 12 | def main(): 13 | parser = ArgumentParser() 14 | parser.add_argument("files", nargs="*") 15 | args = parser.parse_args(sys.argv[1:]) 16 | default_date = datetime.now() 17 | for filename in args.files: 18 | with open(filename) as f: 19 | if filename.endswith(".xml"): 20 | xml_in = f.read() 21 | else: 22 | doc_in = HSReplayDocument.from_log_file(f, date=default_date, build=BUILD) 23 | xml_in = doc_in.to_xml(pretty=True) 24 | xml_file_in = BytesIO(xml_in.encode("utf-8")) 25 | doc_out = HSReplayDocument.from_xml_file(xml_file_in) 26 | assert doc_out.build, "Can't find build in output file" 27 | xml_out = doc_out.to_xml(pretty=True) 28 | 29 | if xml_in != xml_out: 30 | with open("in.xml", "w") as f, open("out.xml", "w") as f2: 31 | f.write(xml_in) 32 | f2.write(xml_out) 33 | raise Exception("%r: Log -> XML -> Document -> XML: FAIL" % (filename)) 34 | else: 35 | print("%r: Log -> XML -> Document -> XML: SUCCESS" % (filename)) 36 | 37 | packet_tree_in = doc_out.to_packet_tree() 38 | doc_out2 = HSReplayDocument.from_packet_tree(packet_tree_in, build=doc_out.build) 39 | xml_out2 = doc_out2.to_xml(pretty=True) 40 | 41 | if xml_in != xml_out2: 42 | with open("in.xml", "w") as f, open("out2.xml", "w") as f2: 43 | f.write(xml_in) 44 | f2.write(xml_out2) 45 | raise Exception("%r: Document -> PacketTree -> Document: FAIL" % (filename)) 46 | else: 47 | print("%r: Document -> PacketTree -> Document: SUCCESS" % (filename)) 48 | 49 | 50 | if __name__ == "__main__": 51 | main() 52 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HearthSim/python-hsreplay/ef8f65b8204c3fa43c2fd1ab1be3672389727503/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 5 | LOG_DATA_DIR = os.path.join(BASE_DIR, "logdata") 6 | LOG_DATA_GIT = "https://github.com/HearthSim/hsreplay-test-data" 7 | 8 | 9 | def pytest_configure(config): 10 | if not os.path.exists(LOG_DATA_DIR): 11 | proc = subprocess.Popen(["git", "clone", LOG_DATA_GIT, LOG_DATA_DIR]) 12 | assert proc.wait() == 0 13 | 14 | 15 | def logfile(path): 16 | return os.path.join(LOG_DATA_DIR, "hslog-tests", path) 17 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from hsreplay import elements 4 | 5 | TS = datetime(2016, 6, 7) 6 | 7 | 8 | def test_game_node(): 9 | node = elements.GameNode(TS, None, 1, 2, False) 10 | 11 | assert node.to_xml_string() == ( 12 | '' 13 | ) 14 | 15 | 16 | def test_game_entity_node(): 17 | node = elements.GameEntityNode(TS, 1) 18 | 19 | assert node.to_xml_string() == '' 20 | 21 | 22 | def test_player_node(): 23 | node = elements.PlayerNode(TS, 2, 1, 144115188075855872, 0, "The Innkeeper") 24 | assert node.to_xml_string() == ( 25 | '' 27 | ) 28 | 29 | node = elements.PlayerNode(TS, 3, 2, 144115193835963207, 0, "Player One", 0, None, 62) 30 | assert node.to_xml_string() == ( 31 | '' 33 | ) 34 | 35 | 36 | def test_player_node_with_deck(): 37 | node = elements.PlayerNode(TS, 2, 1, 144115188075855872, 0, "The Innkeeper") 38 | node.deck = ["EX1_001", "CS2_231"] 39 | assert node.to_xml_string() == ( 40 | '' 42 | '' 43 | ) 44 | 45 | 46 | def test_deck_node(): 47 | node = elements.DeckNode(TS) 48 | assert node.to_xml_string() == "" 49 | 50 | 51 | def test_card_node(): 52 | node = elements.CardNode(TS, "EX1_001") 53 | node.to_xml_string() == '' 54 | 55 | node = elements.CardNode(TS, "EX1_001", True) 56 | assert node.to_xml_string() == '' 57 | 58 | 59 | def test_full_entity_node(): 60 | node = elements.FullEntityNode(TS, 4, "EX1_001") 61 | assert node.to_xml_string() == '' 62 | 63 | 64 | def test_show_entity_node(): 65 | node = elements.ShowEntityNode(TS, 4, "EX1_001") 66 | assert node.to_xml_string() == '' 67 | 68 | 69 | def test_block_node(): 70 | node = elements.BlockNode(TS, 2, 1) 71 | assert node.to_xml_string() == '' 72 | 73 | 74 | def test_meta_data_node(): 75 | node = elements.MetaDataNode(TS, 1, None, 1) 76 | assert node.to_xml_string() == '' 77 | 78 | node = elements.MetaDataNode(TS, 1, 2, 1) 79 | assert node.to_xml_string() == '' 80 | 81 | 82 | def test_meta_data_node_with_info(): 83 | node = elements.MetaDataNode(TS, 1, 2, 1) 84 | info_node = elements.MetaDataInfoNode(TS, 3, 4) 85 | node.nodes.append(info_node) 86 | assert node.to_xml_string() == ( 87 | '' 88 | '' 89 | ) 90 | 91 | 92 | def test_tag_node(): 93 | node = elements.TagNode(TS, 49, 1) 94 | assert node.to_xml_string() == '' 95 | 96 | 97 | def test_tag_change_node(): 98 | node = elements.TagChangeNode(TS, 1, 49, 1) 99 | assert node.to_xml_string() == '' 100 | 101 | 102 | def test_hide_entity_node(): 103 | node = elements.HideEntityNode(TS, 4, 3) 104 | assert node.to_xml_string() == '' 105 | 106 | 107 | def test_change_entity_node(): 108 | node = elements.ChangeEntityNode(TS, 4, "EX1_001") 109 | assert node.to_xml_string() == ( 110 | '' 111 | ) 112 | 113 | 114 | def test_choices_node(): 115 | node = elements.ChoicesNode(TS, 2, 1, None, 1, 0, 4, 2) 116 | assert node.to_xml_string() == ( 117 | '' 118 | ) 119 | 120 | 121 | def test_choice_node(): 122 | node = elements.ChoiceNode(TS, 1, 4) 123 | assert node.to_xml_string() == '' 124 | 125 | 126 | def test_chosen_entities_node(): 127 | node = elements.ChosenEntitiesNode(TS, 2, 5) 128 | assert node.to_xml_string() == '' 129 | 130 | 131 | def test_send_choices_node(): 132 | node = elements.SendChoicesNode(TS, 3, 1) 133 | assert node.to_xml_string() == '' 134 | 135 | 136 | def test_options_node(): 137 | node = elements.OptionsNode(TS, 1) 138 | assert node.to_xml_string() == '' 139 | 140 | 141 | def test_options_node_with_options(): 142 | node = elements.OptionsNode(TS, 1) 143 | option_node = elements.OptionNode(TS, 1, 4, None, None, 1) 144 | node.nodes.append(option_node) 145 | 146 | assert node.to_xml_string() == ( 147 | '' 148 | '' 149 | ) 150 | 151 | 152 | def test_sub_option_node(): 153 | node = elements.SubOptionNode(TS, 1, 6) 154 | assert node.to_xml_string() == '' 155 | 156 | node = elements.SubOptionNode(TS, 1, 6, 1, None) 157 | assert node.to_xml_string() == '' 158 | 159 | node = elements.SubOptionNode(TS, 1, 6, 8, 7) 160 | assert node.to_xml_string() == '' 161 | 162 | 163 | def test_option_target_node(): 164 | node = elements.OptionTargetNode(TS, 1, 6) 165 | assert node.to_xml_string() == '' 166 | 167 | node = elements.OptionTargetNode(TS, 1, 6, 1, None) 168 | assert node.to_xml_string() == '' 169 | 170 | node = elements.OptionTargetNode(TS, 1, 6, 8, 7) 171 | assert node.to_xml_string() == '' 172 | 173 | 174 | def test_send_option_node(): 175 | node = elements.SendOptionNode(TS, 1, 0, 0, -1) 176 | assert node.to_xml_string() == ( 177 | '' 178 | ) 179 | 180 | 181 | def test_target_no_entity(): 182 | node = elements.OptionTargetNode(TS, 1, None, None, None) 183 | assert node.to_xml_string() == ( 184 | '' 185 | ) 186 | 187 | 188 | def test_reset_game_node(): 189 | node = elements.ResetGameNode(TS) 190 | assert node.to_xml_string() == ( 191 | '' 192 | ) 193 | 194 | 195 | def test_sub_spell_node(): 196 | node = elements.SubSpellNode(TS, "Nothing:123456", 1, 1) 197 | assert node.to_xml_string() == ( 198 | '' 199 | ) 200 | 201 | 202 | def test_spell_node_with_target(): 203 | node = elements.SubSpellNode(TS, "Nothing:123456", 1, 1) 204 | target_node = elements.SubSpellTargetNode(TS, 3, 4) 205 | node.nodes.append(target_node) 206 | assert node.to_xml_string() == ( 207 | '' 208 | '' 209 | ) 210 | -------------------------------------------------------------------------------- /tests/test_stream.py: -------------------------------------------------------------------------------- 1 | import filecmp 2 | import tempfile 3 | from io import BytesIO, StringIO 4 | from typing import Optional, Type 5 | 6 | import lxml 7 | from hslog import LogParser 8 | from hslog.filter import BattlegroundsLogFilter 9 | 10 | from hsreplay import SYSTEM_DTD 11 | from hsreplay.document import HSReplayDocument 12 | from hsreplay.stream import game_to_xml_stream 13 | from tests.conftest import logfile 14 | 15 | 16 | def assert_game_to_xml_equal( 17 | logname: str, 18 | log_filter_cls: Optional[Type[BattlegroundsLogFilter]] = None 19 | ): 20 | parser = LogParser() 21 | with open(logname) as f: 22 | if log_filter_cls is not None: 23 | parser.read(log_filter_cls(f)) 24 | else: 25 | parser.read(f) 26 | 27 | with ( 28 | tempfile.NamedTemporaryFile() as t1, 29 | tempfile.NamedTemporaryFile(mode="w+") as t2 30 | ): 31 | with lxml.etree.xmlfile(t1, encoding="utf-8") as xf: 32 | xf.write_declaration() 33 | xf.write_doctype('' % SYSTEM_DTD), 34 | 35 | with xf.element("HSReplay", attrib={"version": "1.7"}): 36 | xf.write("\n") 37 | for game in parser.games: 38 | game_to_xml_stream(game, xf, indent=2) 39 | 40 | xf.flush() 41 | t1.write(b"\n") 42 | t1.flush() 43 | 44 | doc = HSReplayDocument.from_packet_tree(parser.games) 45 | xml = doc.to_xml(pretty=True) 46 | t2.write(xml) 47 | t2.flush() 48 | 49 | assert filecmp.cmp(t1.name, t2.name, shallow=False) 50 | 51 | 52 | def test_game_to_xml_stream_compat_hearthstone(): 53 | assert_game_to_xml_equal(logfile("88998_missing_player_hash.power.log")) 54 | 55 | 56 | def test_game_to_xml_stream_compat_battlegrounds(): 57 | assert_game_to_xml_equal( 58 | logfile("139963_battlegrounds_perfect_game.power.log"), 59 | log_filter_cls=BattlegroundsLogFilter 60 | ) 61 | 62 | 63 | def test_game_to_xml_stream_compat_mercenaries(): 64 | assert_game_to_xml_equal(logfile("93227_mercenaries_solo_bounty.power.log")) 65 | 66 | 67 | def test_game_to_xml_stream_annotations(): 68 | sio = BytesIO() 69 | with lxml.etree.xmlfile(sio, encoding="utf-8") as xf: 70 | parser = LogParser() 71 | with open(logfile("88998_missing_player_hash.power.log")) as f: 72 | parser.read(f) 73 | game_meta = { 74 | "id": 4390995, 75 | "hs_game_type": 7, 76 | "format": 2, 77 | "scenario_id": 2 78 | } 79 | player_meta = { 80 | 1: { 81 | "rank": 25, 82 | "cardback": 157, 83 | "deck": [ 84 | "RLK_042", "RLK_042", "NX2_036", "NX2_036", "RLK_505", "RLK_505", 85 | "RLK_121", "RLK_121", "RLK_516", "RLK_516", "RLK_503", "RLK_503", 86 | "RLK_833", "RLK_833", "RLK_834", "RLK_834", "RLK_824", "RLK_824", 87 | "TSC_052", "TSC_052", "RLK_511", "RLK_511", "RLK_063", "RLK_063", 88 | "RLK_224", "RLK_958", "RLK_958", "RLK_025", "RLK_025", "RLK_223" 89 | ] 90 | }, 91 | 2: {"rank": 24, "cardback": 157}, 92 | } 93 | 94 | with xf.element("HSReplay", attrib={"version": "1.7"}): 95 | xf.write("\n") 96 | game_to_xml_stream( 97 | parser.games[0], 98 | xf, 99 | game_meta=game_meta, 100 | player_manager=parser.player_manager, 101 | player_meta=player_meta, 102 | ) 103 | 104 | lines = StringIO(sio.getvalue().decode("utf-8")).readlines() 105 | game_line = lines[1] 106 | player_1_line = lines[17] 107 | player_2_line = lines[82] 108 | 109 | assert game_line.strip() == ( 110 | '' 111 | ) 112 | 113 | assert player_1_line.strip() == ( 114 | '' 116 | ) 117 | 118 | assert player_2_line.strip() == ( 119 | '' 121 | ) 122 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39, flake8 3 | 4 | [testenv] 5 | commands = pytest 6 | deps = 7 | pytest==5.0.1 8 | 9 | [testenv:flake8] 10 | commands = flake8 hsreplay 11 | deps = 12 | flake8 13 | flake8-import-order 14 | flake8-quotes 15 | 16 | [flake8] 17 | ignore = E117, W191, I201, W504 18 | max-line-length = 100 19 | exclude = .tox 20 | import-order-style = smarkets 21 | inline-quotes = " 22 | --------------------------------------------------------------------------------