diff --git a/pyminion/core.py b/pyminion/core.py index 8321fbd..5daab88 100644 --- a/pyminion/core.py +++ b/pyminion/core.py @@ -1,7 +1,7 @@ import logging import random from collections import Counter -from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, List, Optional, Set, Tuple if TYPE_CHECKING: from pyminion.game import Game @@ -237,14 +237,19 @@ def __init__( class Pile(AbstractDeck): - def __init__(self, cards: Optional[List[Card]] = None): + def __init__(self, cards: List[Card]): super().__init__(cards) - if cards and len(set(cards)) == 1: - self.name = cards[0].name - elif cards: - self.name = "Mixed" - else: - self.name = None + assert len(cards) > 0 + + all_names: Set[str] = set() + unique_names: List[str] = [] + for card in cards: + name = card.name + if name not in all_names: + unique_names.append(name) + all_names.add(name) + + self.name = "/".join(unique_names) def remove(self, card: Card) -> Card: if len(self.cards) < 1: @@ -269,11 +274,16 @@ class Supply: """ - def __init__(self, piles: Optional[List[Pile]] = None): - if piles: - self.piles = piles - else: - self.piles = [] + def __init__( + self, + basic_score_piles: List[Pile], + basic_treasure_piles: List[Pile], + kingdom_piles: List[Pile], + ): + self.basic_score_piles = basic_score_piles + self.basic_treasure_piles = basic_treasure_piles + self.kingdom_piles = kingdom_piles + self.piles = basic_score_piles + basic_treasure_piles + kingdom_piles def __repr__(self): return str(self.available_cards()) @@ -281,6 +291,27 @@ def __repr__(self): def __len__(self): return len(self.piles) + def _get_pile_str(self, pile: Pile, name_padding: int, player: "Player", game: "Game") -> str: + count_str = f"({len(pile)})" + s = f"{count_str:>4}" + if len(pile) == 0: + s += " $-" + else: + s += f" ${pile.cards[0].get_cost(player, game)}" + s += f" {pile.name:{name_padding}}" + return s + + def get_pretty_string(self, player: "Player", game: "Game") -> str: + max_len = max(len(pile.name) for pile in self.piles) + kingdom_top = self.kingdom_piles[5:] + kingdom_bottom = self.kingdom_piles[:5] + s = "\nSupply:\n" + s += " ".join(f'{self._get_pile_str(pile, max_len, player, game)}' for pile in self.basic_score_piles) + "\n" + s += " ".join(f'{self._get_pile_str(pile, max_len, player, game)}' for pile in self.basic_treasure_piles) + "\n" + s += " ".join(f'{self._get_pile_str(pile, max_len, player, game)}' for pile in kingdom_top) + "\n" + s += " ".join(f'{self._get_pile_str(pile, max_len, player, game)}' for pile in kingdom_bottom) + "\n" + return s + def get_pile(self, pile_name: str) -> Pile: """ Get a pile by name. diff --git a/pyminion/game.py b/pyminion/game.py index f7e8664..1132107 100644 --- a/pyminion/game.py +++ b/pyminion/game.py @@ -80,16 +80,13 @@ def __init__( f_handler.setFormatter(f_format) logger.addHandler(f_handler) - def _create_basic_piles(self) -> List[Pile]: + def _create_basic_score_piles(self) -> List[Pile]: """ - Create the basic piles that are applicable to almost all games of Dominion. + Create the basic victory and curse piles that are applicable to almost all games of Dominion. """ basic_cards: List[Card] = [ - copper, - silver, - gold, estate, duchy, province, @@ -103,6 +100,25 @@ def _create_basic_piles(self) -> List[Pile]: return basic_piles + def _create_basic_treasure_piles(self) -> List[Pile]: + """ + Create the basic treasure piles that are applicable to almost all games of Dominion. + + """ + + basic_cards: List[Card] = [ + copper, + silver, + gold, + ] + + basic_piles = [ + Pile([card] * card.get_pile_starting_count(self)) + for card in basic_cards + ] + + return basic_piles + def _create_kingdom_piles(self) -> List[Pile]: """ Create the kingdom piles that vary from kingdom to kingdom. @@ -139,7 +155,12 @@ def _create_kingdom_piles(self) -> List[Pile]: kingdom_ten = random.sample(kingdom_options, KINGDOM_PILES - chosen_cards) random_piles = [Pile([card] * card.get_pile_starting_count(self)) for card in kingdom_ten] - return chosen_piles + random_piles + piles = chosen_piles + random_piles + + # sort piles by cost and name + piles.sort(key=lambda pile: (pile.cards[0].get_cost(self.players[0], self), pile.name)) + + return piles def _create_supply(self) -> Supply: """ @@ -149,11 +170,12 @@ def _create_supply(self) -> Supply: """ - basic_piles = self._create_basic_piles() + basic_score_piles = self._create_basic_score_piles() + basic_treasure_piles = self._create_basic_treasure_piles() kingdom_piles = self._create_kingdom_piles() - all_piles = basic_piles + kingdom_piles + all_piles = basic_score_piles + basic_treasure_piles + kingdom_piles self.all_game_cards = [pile.cards[0] for pile in all_piles] - return Supply(all_piles) + return Supply(basic_score_piles, basic_treasure_piles, kingdom_piles) def start(self) -> None: logger.info("\nStarting Game...\n") @@ -161,7 +183,7 @@ def start(self) -> None: self.effect_registry.reset() self.supply = self._create_supply() - logger.info(f"Supply: \n{self.supply}") + logger.info(self.supply.get_pretty_string(self.players[0], self)) for card in self.all_game_cards: card.set_up(self) diff --git a/pyminion/human.py b/pyminion/human.py index 65c6638..b6eef00 100644 --- a/pyminion/human.py +++ b/pyminion/human.py @@ -18,6 +18,31 @@ logger = logging.getLogger() +def get_matches(input_str: str, options: List[str]) -> List[str]: + """ + Find matches in a list of options for a user input string + + """ + matches: List[str] = [] + + input_split = input_str.casefold().split() + input_formatted = ' '.join(input_split) + for option in options: + option_folded = option.casefold() + option_split = option_folded.split() + if input_formatted == option_folded: # check for an exact match + return [option] + elif len(input_split) <= len(option_split): + # check if each part of the option starts with the corresponding input part + for i in range(len(input_split)): + if not option_split[i].startswith(input_split[i]): + break + else: + matches.append(option) + + return matches + + def validate_input( func: Optional[Callable] = None, exceptions: Union[Tuple[Type[Exception], ...], Type[Exception]] = (), @@ -87,8 +112,8 @@ def binary_decision(prompt: str) -> bool: def single_card_decision( - prompt: str, valid_cards: List[Card], valid_mixin: str = "placeholder" -) -> Optional[Union[Card, str]]: + prompt: str, valid_cards: List[Card] +) -> Optional[Card]: """ Get user input when given the option to select one card @@ -107,16 +132,19 @@ def single_card_decision( if not card_input: return None - if card_input == valid_mixin: - return valid_mixin - - for card in valid_cards: - if card_input.casefold() == card.name.casefold(): - return card + cards_dict = {card.name: card for card in valid_cards} - raise InvalidSingleCardInput( - f"Invalid input, {card_input} is not a valid selection" - ) + matches = get_matches(card_input, list(cards_dict.keys())) + if len(matches) == 0: + raise InvalidSingleCardInput( + f"Invalid input, {card_input} is not a valid selection" + ) + elif len(matches) == 1: + return cards_dict[matches[0]] + else: + raise InvalidSingleCardInput( + f"Invalid input, multiple matches for {card_input}: " + ", ".join(matches) + ) def multiple_card_decision( @@ -141,20 +169,24 @@ def multiple_card_decision( if not card_input: return [] - if allow_all and card_input == "all": + if allow_all and card_input.strip().casefold() == "all": return valid_cards + cards_dict = {card.name: card for card in valid_cards} card_strings = [x.strip() for x in card_input.split(",")] selected_cards = [] for card_string in card_strings: - for card in valid_cards: - if card_string.casefold() == card.name.casefold(): - selected_cards.append(card) - break - else: + matches = get_matches(card_string, list(cards_dict.keys())) + if len(matches) == 0: raise InvalidMultiCardInput( f"Invalid input, {card_string} is not a valid card" ) + elif len(matches) == 1: + selected_cards.append(cards_dict[matches[0]]) + else: + raise InvalidMultiCardInput( + f"Invalid input, multiple matches for {card_string}: " + ", ".join(matches) + ) selected_count = Counter(selected_cards) valid_count = Counter(valid_cards) @@ -253,8 +285,6 @@ def action_phase_decision( prompt="Choose an action card to play: ", valid_cards=valid_actions, ) - if isinstance(card, str): - raise InvalidSingleCardInput("You must choose a valid card") return card @@ -283,8 +313,6 @@ def buy_phase_decision( prompt="Choose a card to buy: ", valid_cards=valid_cards, ) - if isinstance(card, str): - raise InvalidSingleCardInput("You must choose a valid card") return card @@ -535,11 +563,7 @@ def multi_play_decision( ) -> Optional["Card"]: result = single_card_decision(prompt, valid_cards) - if isinstance(result, str): - raise InvalidSingleCardInput( - f"Invalid response, you must name a valid card" - ) - elif required and result is None: + if required and result is None: raise InvalidSingleCardInput( f"Invalid response, you must name a valid card" ) diff --git a/pyminion/player.py b/pyminion/player.py index c434cd3..4187a65 100644 --- a/pyminion/player.py +++ b/pyminion/player.py @@ -315,7 +315,7 @@ def start_treasure_phase(self, game: "Game") -> None: def start_buy_phase(self, game: "Game") -> None: while self.state.buys > 0: - logger.info(f"\nSupply:{game.supply}") + logger.info(game.supply.get_pretty_string(self, game)) logger.info(f"Money: {self.state.money}") logger.info(f"Buys: {self.state.buys}") diff --git a/tests/conftest.py b/tests/conftest.py index 54c865f..5797e15 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,7 +120,7 @@ def supply(): silvers = Pile(silver_cards) gold_cards: List["Card"] = [gold] * 30 golds = Pile(gold_cards) - supply = Supply([estates, duchies, provinces, coppers, silvers, golds]) + supply = Supply([estates, duchies, provinces], [coppers, silvers, golds], []) return supply diff --git a/tests/test_cards/test_actions/test_artisan.py b/tests/test_cards/test_actions/test_artisan.py index 1593eda..501b303 100644 --- a/tests/test_cards/test_actions/test_artisan.py +++ b/tests/test_cards/test_actions/test_artisan.py @@ -5,48 +5,48 @@ def test_artisan_valid_gain_same_topdeck(human: Human, game: Game, monkeypatch): human.hand.add(artisan) - assert len(game.supply.piles[1]) == 40 + assert game.supply.pile_length(silver.name) == 40 assert len(human.hand) == 1 responses = iter(["silver", "silver"]) monkeypatch.setattr("builtins.input", lambda input: next(responses)) - human.hand.cards[0].play(human, game) + human.play(artisan, game) assert len(human.hand) == 0 assert human.deck.cards[-1] is silver assert len(human.playmat) == 1 assert human.state.actions == 0 - assert len(game.supply.piles[1]) == 39 + assert game.supply.pile_length(silver.name) == 39 def test_artisan_invalid_gain(human: Human, game: Game, monkeypatch): human.hand.add(artisan) - assert len(game.supply.piles[1]) == 40 + assert game.supply.pile_length(silver.name) == 40 assert len(human.hand) == 1 responses = iter(["gold", "silver", "silver"]) monkeypatch.setattr("builtins.input", lambda input: next(responses)) - human.hand.cards[0].play(human, game) + human.play(artisan, game) assert len(human.hand) == 0 assert human.deck.cards[-1] is silver assert len(human.playmat) == 1 assert human.state.actions == 0 - assert len(game.supply.piles[1]) == 39 + assert game.supply.pile_length(silver.name) == 39 def test_artisan_valid_gain_diff_topdeck(human: Human, game: Game, monkeypatch): human.hand.add(artisan) human.hand.add(artisan) - assert len(game.supply.piles[1]) == 40 + assert game.supply.pile_length(silver.name) == 40 assert len(human.hand) == 2 responses = iter(["silver", "artisan"]) monkeypatch.setattr("builtins.input", lambda input: next(responses)) - human.hand.cards[0].play(human, game) + human.play(artisan, game) assert len(human.hand) == 1 assert human.deck.cards[-1] is artisan assert len(human.playmat) == 1 assert human.state.actions == 0 - assert len(game.supply.piles[1]) == 39 + assert game.supply.pile_length(silver.name) == 39 diff --git a/tests/test_cards/test_actions/test_workshop.py b/tests/test_cards/test_actions/test_workshop.py index fbf06aa..666e819 100644 --- a/tests/test_cards/test_actions/test_workshop.py +++ b/tests/test_cards/test_actions/test_workshop.py @@ -10,9 +10,9 @@ def test_workshop_gain_valid(human: Human, game: Game, monkeypatch): # mock decision = input() as "Copper" to discard monkeypatch.setattr("builtins.input", lambda _: "Estate") - human.hand.cards[0].play(human, game) + human.play(workshop, game) assert len(human.playmat) == 1 assert len(human.discard_pile) == 1 assert human.state.actions == 0 assert human.discard_pile.cards[0].name == "Estate" - assert len(game.supply.piles[3]) == 4 + assert game.supply.pile_length("Estate") == 4 diff --git a/tests/test_core/test_pile.py b/tests/test_core/test_pile.py index a9c05c9..e6ab7f6 100644 --- a/tests/test_core/test_pile.py +++ b/tests/test_core/test_pile.py @@ -1,17 +1,12 @@ import pytest -from pyminion.core import Pile +from pyminion.core import Card, Pile from pyminion.exceptions import EmptyPile from pyminion.expansions.base import copper, estate - - -def test_make_empty_pile(): - empty = Pile() - assert len(empty) == 0 - assert empty.name is None +from typing import List def test_make_pile(): - estates = [estate for x in range(8)] + estates: List[Card] = [estate for x in range(8)] estate_pile = Pile(estates) assert len(estate_pile) == 8 assert estate_pile.name == "Estate" @@ -20,7 +15,7 @@ def test_make_pile(): def test_make_mixed_pile(): mixed = Pile([estate, copper]) assert len(mixed) == 2 - assert mixed.name == "Mixed" + assert mixed.name == "Estate/Copper" def test_draw_empty_pile(): diff --git a/tests/test_core/test_supply.py b/tests/test_core/test_supply.py index 3ce2c84..225956b 100644 --- a/tests/test_core/test_supply.py +++ b/tests/test_core/test_supply.py @@ -1,5 +1,5 @@ import pytest -from pyminion.core import Card, Pile, Supply +from pyminion.core import Card, CardType, Pile, Supply from pyminion.exceptions import EmptyPile, PileNotFound from pyminion.expansions.base import copper, duchy, estate, gold, province, silver @@ -12,7 +12,7 @@ def test_create_supply(): coppers = Pile([copper for x in range(8)]) silvers = Pile([silver for x in range(8)]) golds = Pile([gold for x in range(8)]) - supply = Supply([estates, duchies, provinces, coppers, silvers, golds]) + supply = Supply([estates, duchies, provinces], [coppers, silvers, golds], []) assert len(supply) == 6 @@ -32,7 +32,7 @@ def test_gain_empty_pile(supply: Supply): def test_pile_not_found(supply: Supply): - fake_card = Card(name="fake", cost=0, type="test") + fake_card = Card(name="fake", cost=0, type=(CardType.Action,)) with pytest.raises(PileNotFound): supply.gain_card(fake_card) diff --git a/tests/test_players/test_human.py b/tests/test_players/test_human.py index 8ae023d..ddeeb40 100644 --- a/tests/test_players/test_human.py +++ b/tests/test_players/test_human.py @@ -1,9 +1,59 @@ from pyminion.expansions.base import copper, moat, witch from pyminion.game import Game -from pyminion.human import Human +from pyminion.human import Human, get_matches import pytest +def test_get_matches_all_match(): + names = ["Market", "Masquerade", "Merchant"] + matches = get_matches("m", names) + assert matches == ["Market", "Masquerade", "Merchant"] + + names = ["Market", "Masquerade", "Merchant"] + matches = get_matches("M", names) + assert matches == ["Market", "Masquerade", "Merchant"] + + +def test_get_matches_partial_match(): + names = ["Market", "Masquerade", "Merchant"] + matches = get_matches("ma", names) + assert matches == ["Market", "Masquerade"] + + +def test_get_matches_no_match(): + names = ["Market", "Masquerade", "Merchant"] + matches = get_matches("x", names) + assert matches == [] + + +def test_get_matches_multi_word(): + names = ["King's Cache", "King's Castle", "King's Court"] + matches = get_matches("k", names) + assert matches == ["King's Cache", "King's Castle", "King's Court"] + + names = ["King's Cache", "King's Castle", "King's Court"] + matches = get_matches("king's ca", names) + assert matches == ["King's Cache", "King's Castle"] + + names = ["King's Cache", "King's Castle", "King's Court"] + matches = get_matches("k ca", names) + assert matches == ["King's Cache", "King's Castle"] + + names = ["King's Cache", "King's Castle", "King's Court"] + matches = get_matches("k cas", names) + assert matches == ["King's Castle"] + + +def test_get_matches_multi_word_exact_match(): + names = ["Market", "Market Square"] + matches = get_matches("marke", names) + assert matches == ["Market", "Market Square"] + + names = ["Market", "Market Square"] + matches = get_matches("market", names) + assert matches == ["Market"] + + def test_yes_input(human: Human, game: Game, monkeypatch): monkeypatch.setattr("builtins.input", lambda _: "y") assert human.decider.binary_decision(prompt="test", card=copper, player=human, game=game) is True