From d63dfdca3f0a9ed27094f1e5af704654085131b8 Mon Sep 17 00:00:00 2001 From: Mateus Cechetto Date: Mon, 10 Aug 2026 13:09:04 -0300 Subject: [PATCH] feat: handle beatrix sideboard card as initial deck --- hearthstone/entities.py | 47 ++++++++++++++++++++++++-- tests/test_entities.py | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/hearthstone/entities.py b/hearthstone/entities.py index 9418753..0e58dff 100644 --- a/hearthstone/entities.py +++ b/hearthstone/entities.py @@ -19,6 +19,7 @@ def __init__(self, id): self.initial_creator = 0 self.initial_zone: Zone = Zone.INVALID self.initial_zone_position = 0 + self.is_initial_entity = False self._initial_controller = 0 def __repr__(self): @@ -119,6 +120,7 @@ def register_entity(self, entity: Entity) -> None: if isinstance(entity, Player): self.players.append(entity) elif not self.setup_done: + entity.is_initial_entity = True self.initial_entities.append(entity) # Infer player class and card from "Maestra of the Masquerade" revealing herself @@ -218,8 +220,11 @@ def initial_deck(self) -> Iterator["Card"]: continue # Allow CREATOR=1 because of monster hunt decks. + # Cards shuffled into the deck at the start of the game by a card with a + # sideboard (eg. Commander Beatrix) are part of the deck list the player + # submitted, so they belong here too. # Everything else is likely a false positive. - if entity.initial_creator > 1: + if entity.initial_creator > 1 and not entity.is_sideboard_deck_card: continue yield entity @@ -330,6 +335,29 @@ def can_be_in_deck(self) -> bool: return CardType(card_type).playable + def _is_sideboard_deck_card(self, creator_id: int) -> bool: + """ + Whether this card was shuffled into the deck during setup by a card that has a + sideboard (eg. Commander Beatrix, who adds copies of her sideboard card). + + Those copies are created after the initial deck is dumped and carry a creator, but + their identity is picked during deckbuilding and they are part of the deck list the + player submitted. That sets them apart from cards generated by effects such as + Azalina Soulsever's deck rule, whose identity is random and unknowable up front. + """ + if not self.is_initial_entity or self.initial_zone != Zone.DECK: + return False + + if not creator_id or self.game is None: + return False + + creator = self.game.find_entity_by_id(creator_id) + return bool(creator and creator.tags.get(GameTag.MAX_SIDEBOARD_CARDS, 0)) + + @property + def is_sideboard_deck_card(self) -> bool: + return self._is_sideboard_deck_card(self.initial_creator) + 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. @@ -372,12 +400,25 @@ def reveal(self, card_id: str, tags: GameTagsDict) -> None: self.revealed = True self.card_id = card_id - if ( + created = ( tags.get(GameTag.CREATOR_DBID, 0) or + tags.get(GameTag.DISPLAYED_CREATOR, 0) + ) + + # The creator may only be known from an earlier tag change, so fall back to it. + creator_id = ( + tags.get(GameTag.CREATOR, 0) or tags.get(GameTag.DISPLAYED_CREATOR, 0) or - tags.get(GameTag.TRANSFORMED_FROM_CARD, 0) + self.initial_creator + ) + + if ( + tags.get(GameTag.TRANSFORMED_FROM_CARD, 0) or + (created and not self._is_sideboard_deck_card(creator_id)) ): # Cards that are revealed with a creator most likely have been transformed. + # Sideboard cards shuffled into the deck at the start of the game are the + # exception: they are revealed with their real, original card id. self.is_original_entity = False self._capture_initial_card_id(card_id, tags) diff --git a/tests/test_entities.py b/tests/test_entities.py index 5790995..a187a8a 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -161,6 +161,81 @@ def test_initial_deck_with_cards_generated_at_start_of_game(self, game, player): assert generated.initial_creator == azalina.id assert list(player.initial_deck) == [azalina] + def test_initial_deck_with_sideboard_cards_shuffled_in_at_start_of_game( + self, game, player + ): + # Commander Beatrix shuffles 10 copies of her sideboard card into the deck + # during CREATE_GAME, before the game is set up. The card was picked during + # deckbuilding, so the copies are part of the submitted deck list. + beatrix = Card(5, None) + beatrix.tags.update({ + GameTag.ZONE: Zone.DECK, + GameTag.CONTROLLER: player.player_id, + }) + game.register_entity(beatrix) + beatrix.reveal("JAIL_397", { + GameTag.CARDTYPE: CardType.MINION, + GameTag.MAX_SIDEBOARD_CARDS: 1, + }) + + # The copies are created knowing nothing but their zone, and the creator only + # follows as a separate tag change. + copy = Card(6, None) + copy.tags.update({ + GameTag.ZONE: Zone.DECK, + GameTag.CONTROLLER: player.player_id, + }) + game.register_entity(copy) + copy.tag_change(GameTag.DISPLAYED_CREATOR, beatrix.id) + + # Once drawn, the copy is revealed with its real card id - alongside the creator + # tags that would normally mark it as a generated card. + copy.reveal("CS2_231", { + GameTag.CARDTYPE: CardType.MINION, + GameTag.CREATOR: beatrix.id, + GameTag.DISPLAYED_CREATOR: beatrix.id, + GameTag.CREATOR_DBID: 126621, + }) + + assert copy.initial_creator == beatrix.id + assert copy.is_original_entity + assert copy.initial_card_id == "CS2_231" + assert list(player.initial_deck) == [beatrix, copy] + assert player.known_starting_deck_list == ["JAIL_397", "CS2_231"] + + def test_initial_deck_with_sideboard_cards_created_after_setup(self, game, player): + # E.T.C., Band Manager also has a sideboard, but its cards are created once it is + # played, long after setup. Those are not deck cards. + etc = Card(5, None) + etc.tags.update({ + GameTag.ZONE: Zone.DECK, + GameTag.CONTROLLER: player.player_id, + }) + game.register_entity(etc) + etc.reveal("ETC_080", { + GameTag.CARDTYPE: CardType.MINION, + GameTag.MAX_SIDEBOARD_CARDS: 3, + }) + + game.tag_change(GameTag.NEXT_STEP, Step.MAIN_ACTION) + + band_card = Card(6, None) + band_card.tags.update({ + GameTag.ZONE: Zone.DECK, + GameTag.CONTROLLER: player.player_id, + }) + game.register_entity(band_card) + band_card.tag_change(GameTag.DISPLAYED_CREATOR, etc.id) + band_card.reveal("CS2_231", { + GameTag.CARDTYPE: CardType.MINION, + GameTag.CREATOR: etc.id, + GameTag.DISPLAYED_CREATOR: etc.id, + }) + + assert not band_card.is_original_entity + assert band_card.initial_card_id is None + assert list(player.initial_deck) == [etc] + def test_initial_deck_with_card_that_is_its_own_creator(self, game, player): # Direhorn Hatchling tags itself as its own DISPLAYED_CREATOR when its Deathrattle # shuffles a Direhorn Matriarch into the deck. It is still an original deck card.