Skip to content

Commit

Permalink
Merge pull request #111 from jbw3/human-improvements
Browse files Browse the repository at this point in the history
Improvements for human players
  • Loading branch information
evanofslack authored Mar 30, 2024
2 parents 1d9a0fd + 7181304 commit fb855ff
Show file tree
Hide file tree
Showing 10 changed files with 197 additions and 75 deletions.
57 changes: 44 additions & 13 deletions pyminion/core.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -269,18 +274,44 @@ 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())

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.
Expand Down
42 changes: 32 additions & 10 deletions pyminion/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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:
"""
Expand All @@ -149,19 +170,20 @@ 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")

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)
Expand Down
76 changes: 50 additions & 26 deletions pyminion/human.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = (),
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"
)
Expand Down
2 changes: 1 addition & 1 deletion pyminion/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
18 changes: 9 additions & 9 deletions tests/test_cards/test_actions/test_artisan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions tests/test_cards/test_actions/test_workshop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit fb855ff

Please sign in to comment.