forked from HearthSim/python-hearthstone
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathentities.py
388 lines (315 loc) · 10.8 KB
/
entities.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
from typing import Dict, Iterable, Iterator, List, Optional, Tuple, Union, cast
from hearthstone.utils import MAESTRA_DISGUISE_DBF_ID, get_original_card_id
from .enums import CardSet, CardType, GameTag, State, Step, Zone
from .types import GameTagsDict
STARTING_HERO_SETS = (CardSet.HERO_SKINS, )
class Entity:
_args: Iterable[str] = ()
def __init__(self, id):
self.id = id
self.game = None
self.tags: GameTagsDict = {}
self.initial_creator = 0
self.initial_zone: Zone = Zone.INVALID
self._initial_controller = 0
def __repr__(self):
return "%s(id=%r, %s)" % (
self.__class__.__name__, self.id,
", ".join("%s=%r" % (k, getattr(self, k)) for k in self._args)
)
@property
def controller(self) -> Optional["Player"]:
return self.game.get_player(self.tags.get(GameTag.CONTROLLER, 0))
@property
def initial_controller(self):
return self.game.get_player(
self._initial_controller or self.tags.get(GameTag.CONTROLLER, 0)
)
@property
def type(self):
return self.tags.get(GameTag.CARDTYPE, CardType.INVALID)
@property
def zone(self):
return self.tags.get(GameTag.ZONE, Zone.INVALID)
def _update_tags(self, tags):
for tag, value in tags.items():
if tag == GameTag.CONTROLLER and not self._initial_controller:
self._initial_controller = self.tags.get(GameTag.CONTROLLER, value)
self.tags.update(tags)
def reset(self):
pass
def tag_change(self, tag, value):
self._update_tags({tag: value})
class Game(Entity):
_args = ("players", )
can_be_in_deck = False
def __init__(self, id):
super(Game, self).__init__(id)
self.players: List[Player] = []
self._entities: Dict[int, Entity] = {}
self.initial_entities: List[Entity] = []
self.initial_state: State = State.INVALID
self.initial_step: Step = Step.INVALID
@property
def entities(self) -> Iterator[Entity]:
yield from self._entities.values()
@property
def current_player(self) -> Optional["Player"]:
for player in self.players:
if player.tags.get(GameTag.CURRENT_PLAYER):
return player
return None
@property
def first_player(self) -> Optional["Player"]:
for player in self.players:
if player.tags.get(GameTag.FIRST_PLAYER):
return player
return None
@property
def setup_done(self) -> bool:
return self.tags.get(GameTag.NEXT_STEP, 0) > Step.BEGIN_MULLIGAN
def get_player(self, value: Union[int, str]) -> Optional["Player"]:
for player in self.players:
if value in (player.player_id, player.name):
return player
return None
def in_zone(self, zone: Zone) -> Iterator[Entity]:
for entity in self.entities:
if entity.zone == zone:
yield entity
def create(self, tags: GameTagsDict) -> None:
self.tags = dict(tags)
self.initial_state = cast(State, self.tags.get(GameTag.STATE, State.INVALID))
self.initial_step = cast(Step, self.tags.get(GameTag.STEP, Step.INVALID))
self.register_entity(self)
def register_entity(self, entity: Entity) -> None:
entity.game = self
self._entities[entity.id] = entity
entity.initial_zone = entity.zone
if isinstance(entity, Player):
self.players.append(entity)
elif not self.setup_done:
self.initial_entities.append(entity)
# Infer player class and card from "Maestra of the Masquerade" revealing herself
if (
entity.type == CardType.HERO and
entity.tags.get(GameTag.CREATOR_DBID) == MAESTRA_DISGUISE_DBF_ID
):
player = entity.controller
if player is not None:
# The player was playing Maestra, which created a fake hero at the start of
# the game. After playing a Rogue card, the real hero is revealed, which
# creates a new hero entity. To ensure that player.starting_hero returns the
# "correct" Rogue hero, we overwrite the initial_hero_entity_id with the new
# one.
player.initial_hero_entity_id = entity.id
# At this point we know that Maestra must be in the starting deck of the
# player, because otherwise the reveal would not happen. Manually add it to
# the list of starting cards
player._known_starting_card_ids.add("SW_050")
# Infer Tourists when they reveal themselves
if (
entity.tags.get(GameTag.ZONE) == Zone.REMOVEDFROMGAME and
entity.tags.get(GameTag.TOURIST, 0) > 0
):
# This might be the fake Tourist that the game pops up to explain why a card was
# present in the player's deck. Double-check that the card was created by the
# Tourist VFX enchantment.
creator_id = entity.tags.get(GameTag.CREATOR)
creator = self.find_entity_by_id(creator_id) if creator_id else None
creator_is_vfx = (
getattr(creator, "card_id") == "VAC_422e"
if creator is not None else False
)
player = entity.controller
tourist_card_id = getattr(entity, "card_id")
if creator_is_vfx and player is not None and tourist_card_id is not None:
player._known_starting_card_ids.add(tourist_card_id)
def reset(self) -> None:
for entity in self.entities:
if entity is self:
continue
entity.reset()
def find_entity_by_id(self, id: int) -> Optional[Entity]:
# int() for LazyPlayer mainly...
id = int(id)
return self._entities.get(id)
class Player(Entity):
_args = ("name", )
UNKNOWN_HUMAN_PLAYER = "UNKNOWN HUMAN PLAYER"
can_be_in_deck = False
def __init__(self, id, player_id, hi, lo, name=None):
super(Player, self).__init__(id)
self.player_id = player_id
self.account_hi = hi
self.account_lo = lo
self.name = name
self.initial_hero_entity_id = 0
self._known_starting_card_ids = set()
def __str__(self) -> str:
return self.name or ""
@property
def names(self) -> Tuple[str, str]:
"""
Returns the player's name and real name.
Returns two empty strings if the player is unknown.
AI real name is always an empty string.
"""
if self.name == self.UNKNOWN_HUMAN_PLAYER:
return "", ""
if not self.is_ai and " " in self.name:
return "", self.name
return self.name, ""
@property
def initial_deck(self) -> Iterator["Card"]:
for entity in self.game.initial_entities:
# Exclude entities that aren't initially owned by the player
if entity.initial_controller != self:
continue
# Exclude entities that aren't initially in the deck
# We include the graveyard because of Souleater's Scythe, that moves
# into the graveyard before the mulligan.
if entity.initial_zone not in (Zone.DECK, Zone.GRAVEYARD):
continue
# Exclude entity types that cannot be in the deck
if not entity.can_be_in_deck:
continue
# Allow CREATOR=1 because of monster hunt decks.
# Everything else is likely a false positive.
if entity.initial_creator > 1:
continue
yield entity
@property
def known_starting_deck_list(self) -> List[str]:
"""
Returns a list of card ids that were present in the player's deck at the start of
game (before Mulligan). May contain duplicates if same card is present multiple
times in the deck. This attempts to reverse revealed transforms (e.g. Zerus, Molten
Blade) and well-known transforms (e.g. Spellstones, Unidentified Objects, Worgens)
so that the initial card id is included rather than the final card id.
"""
original_card_ids = [
get_original_card_id(entity.initial_card_id)
for entity in self.initial_deck if entity.initial_card_id
]
return original_card_ids + [
card_id for card_id in self._known_starting_card_ids
if card_id not in original_card_ids
]
@property
def entities(self) -> Iterator[Entity]:
for entity in self.game.entities:
if entity.controller == self:
yield entity
@property
def hero(self) -> Optional["Card"]:
entity_id = self.tags.get(GameTag.HERO_ENTITY, 0)
if entity_id:
return self.game.find_entity_by_id(entity_id)
else:
# Fallback that should never trigger
for entity in self.in_zone(Zone.PLAY):
if entity.type == CardType.HERO:
return cast(Card, entity)
return None
@property
def heroes(self) -> Iterator["Card"]:
for entity in self.entities:
if entity.type == CardType.HERO:
yield cast(Card, entity)
@property
def starting_hero(self) -> Optional["Card"]:
if self.initial_hero_entity_id:
return cast(Card, self.game.find_entity_by_id(self.initial_hero_entity_id))
# Fallback
heroes = list(self.heroes)
if not heroes:
return None
return heroes[0]
@property
def is_ai(self) -> bool:
return self.account_lo == 0
def in_zone(self, zone) -> Iterator["Entity"]:
for entity in self.entities:
if entity.zone == zone:
yield entity
class Card(Entity):
_args = ("card_id", )
def __init__(self, id, card_id):
super(Card, self).__init__(id)
self.is_original_entity = True
self.initial_card_id = card_id
self.card_id = card_id
self.revealed = False
@property
def base_tags(self) -> GameTagsDict:
if not self.card_id:
return {}
from .cardxml import load
db, _ = load()
return db[self.card_id].tags
def _get_initial_base_tags(self) -> GameTagsDict:
if not self.initial_card_id:
return {}
from .cardxml import load
db, _ = load()
return db[self.initial_card_id].tags
@property
def can_be_in_deck(self) -> bool:
card_type = self.type
if not card_type:
# If we don't know the card type, assume yes
return True
elif card_type == CardType.HERO:
tags = self._get_initial_base_tags()
return (
tags.get(GameTag.CARD_SET, 0) not in STARTING_HERO_SETS and
bool(tags.get(GameTag.COLLECTIBLE, 0))
)
return CardType(card_type).playable
def _capture_initial_card_id(self, card_id: str, tags: GameTagsDict) -> None:
if self.initial_card_id:
# If we already know a previous card id, we do not want to change it.
return
transformed_from_card = tags.get(GameTag.TRANSFORMED_FROM_CARD, 0)
if transformed_from_card:
from .cardxml import load_dbf
db, _ = load_dbf()
card = db.get(transformed_from_card)
if card:
self.initial_card_id = card.card_id
return
if not self.is_original_entity:
# If we know this card was transformed and we don't have an initial_card_id by
# now, it is too late - any card_id we'd capture now would not reflect initial
# one and be wrong.
return
self.initial_card_id = card_id
def _update_tags(self, tags: GameTagsDict) -> None:
super()._update_tags(tags)
if self.is_original_entity and self.initial_creator is None:
creator = tags.get(GameTag.CREATOR, 0)
if creator:
self.initial_creator = creator
def reveal(self, card_id: str, tags: GameTagsDict) -> None:
self.revealed = True
self.card_id = card_id
if (
tags.get(GameTag.CREATOR_DBID, 0) or
tags.get(GameTag.DISPLAYED_CREATOR, 0) or
tags.get(GameTag.TRANSFORMED_FROM_CARD, 0)
):
# Cards that are revealed with a creator most likely have been transformed.
self.is_original_entity = False
self._capture_initial_card_id(card_id, tags)
self._update_tags(tags)
def hide(self) -> None:
self.revealed = False
def change(self, card_id: str, tags) -> None:
self._capture_initial_card_id(card_id, tags)
self.is_original_entity = False
self.card_id = card_id
self._update_tags(tags)
def reset(self) -> None:
self.card_id = None
self.revealed = False