diff --git a/examples/bot_game.py b/examples/bot_game.py index 150b94e..60fe26c 100644 --- a/examples/bot_game.py +++ b/examples/bot_game.py @@ -1,5 +1,9 @@ """ -Run a single game between two bots. +Run a single game between two bots. + +Here we do not log the game to console. +Instead, we save the game output to a file (pyminion.log). +We only print the result of the game to console. """ @@ -14,8 +18,13 @@ game = Game( players=[bm, bm_ultimate], expansions=[base_set], - kingdom_cards=[smithy], + kingdom_cards=[smithy], # specific cards to add to the kingdom + random_order=True, # players start in random order + log_stdout=False, # log the output to stdout + log_file=True, # log the output to file + log_file_name="pyminion.log", # name of file to log output ) if __name__ == "__main__": - game.play() + result = game.play() + print(result) diff --git a/examples/human_game.py b/examples/human_game.py index 44c0800..2d18ef2 100644 --- a/examples/human_game.py +++ b/examples/human_game.py @@ -1,16 +1,23 @@ """ -Play a game through the terminal. Either by yourself, with another human, or against a bot. +Play a game through the terminal. +Either by yourself, with another human, or against a bot. """ + from pyminion.bots.examples import BigMoney -from pyminion.expansions.base import base_set +from pyminion.expansions.base import artisan, bandit, base_set, witch from pyminion.game import Game from pyminion.players import Human human = Human(player_id="Human") -bot = BigMoney(player_id="Bot 1") +bm = BigMoney(player_id="Big Money") -game = Game(players=[human, bot], expansions=[base_set]) +game = Game( + players=[human, bm], + expansions=[base_set], + kingdom_cards=[artisan, bandit, witch], # specific cards to add to the kingdom + random_order=True, # players start in random order +) if __name__ == "__main__": - game.play() + result = game.play() diff --git a/examples/simulation.py b/examples/simulation.py index c126584..f7195d6 100644 --- a/examples/simulation.py +++ b/examples/simulation.py @@ -1,5 +1,5 @@ """ -Simulate multiple games between two or more bots. +Simulate multiple games between two or more bots. """ from pyminion.bots.examples import BigMoney, BigMoneySmithy @@ -11,9 +11,17 @@ bm_smithy = BigMoneySmithy() -game = Game(players=[bm, bm_smithy], expansions=[base_set], kingdom_cards=[smithy]) +game = Game( + players=[bm, bm_smithy], + expansions=[base_set], + kingdom_cards=[smithy], + random_order=False, + log_stdout=False, + log_file=False, +) sim = Simulator(game, iterations=1000) if __name__ == "__main__": - sim.run() + result = sim.run() + print(result) diff --git a/pyminion/__init__.py b/pyminion/__init__.py index 0c48ae5..c6dff60 100644 --- a/pyminion/__init__.py +++ b/pyminion/__init__.py @@ -1,21 +1,10 @@ __version__ = "0.2.2" __author__ = "Evan Slack" - -# INITIALIZE LOGGER - import logging +# initalize logger with no handler. +# handlers are added in the `Game` init logger = logging.getLogger() -logger.setLevel(logging.DEBUG) - -# Create handler -c_handler = logging.StreamHandler() -c_handler.setLevel(logging.DEBUG) - -# Create formatter and add to handler -c_format = logging.Formatter("%(message)s") -c_handler.setFormatter(c_format) - -# Add handler to the logger -logger.addHandler(c_handler) +logger.setLevel((logging.INFO)) +logger.addHandler(logging.NullHandler()) diff --git a/pyminion/game.py b/pyminion/game.py index 0eeaac3..a78efe1 100644 --- a/pyminion/game.py +++ b/pyminion/game.py @@ -8,6 +8,7 @@ from pyminion.expansions.base import (copper, curse, duchy, estate, gold, province, silver) from pyminion.players import Player +from pyminion.result import GameOutcome, GameResult, PlayerSummary logger = logging.getLogger() @@ -22,7 +23,8 @@ class Game: kingdom_cards: Specify any specific cards to be used in the supply. start_deck: List of cards each player will start the game with. Default = [7 Coppers + 3 Estates]. random_order: If True, scrambles the order of players (to offset first player advantage). - use_logger: If True, logs the game to a log file. + log_stdout: If True, logs game to stdout. + log_file: If True, logs game to log file. log_file_name: Name of the file to be logged to. Default = "game.log" """ @@ -34,7 +36,8 @@ def __init__( kingdom_cards: Optional[List[Card]] = None, start_deck: Optional[List[Card]] = None, random_order: bool = True, - use_logger: bool = True, + log_stdout: bool = True, + log_file: bool = False, log_file_name: str = "game.log", ): @@ -49,7 +52,15 @@ def __init__( self.random_order = random_order self.trash = Trash() - if use_logger: + if log_stdout: + # Set up a handler that logs to stdout + c_handler = logging.StreamHandler() + c_handler.setLevel(logging.INFO) + c_format = logging.Formatter("%(message)s") + c_handler.setFormatter(c_format) + logger.addHandler(c_handler) + + if log_file: # Set up a handler that dumps the log to a file f_handler = logging.FileHandler(log_file_name, mode="w") f_handler.setLevel(logging.INFO) @@ -113,7 +124,8 @@ def _create_basic_piles(self) -> List[Pile]: def _create_kingdom_piles(self) -> List[Pile]: """ - Create the kingdom piles that vary from kingdom to kingdom. This should be 10 piles each with 10 cards. + Create the kingdom piles that vary from kingdom to kingdom. + This should be 10 piles each with 10 cards. """ PILE_LENGTH: int = 10 @@ -151,7 +163,9 @@ def _create_kingdom_piles(self) -> List[Pile]: def _create_supply(self) -> Supply: """ - Create a supply consisting of the basic cards avaliable in every kingdom as well as the kingdom specific cards. + Create a supply consisting of basic cards + avaliable in every kingdom as well + as the kingdom specific cards. """ @@ -199,53 +213,103 @@ def is_over(self) -> bool: return False - def play(self): + def play(self) -> GameResult: self.start() while True: for player in self.players: player.take_turn(self) if self.is_over(): - self.get_stats() - return + result = self.summerize_game() + logging.info(f"\n{result}") + return result - def get_winner(self) -> Optional[Player]: + def get_winners(self) -> List[Player]: """ The player with the most victory points wins. If the highest scores are tied at the end of the game, the tied player who has had the fewest turns wins the game. If the tied players have had the same number of turns, they tie. - Returns the winning player or None if there is a tie. + Returns a list of players. If there is a single player in + the list, that is the sole winner. If there are multiple players + in the list, they are have tied for first. """ + # if one player only, they win by default if len(self.players) == 1: - return self.players[0] + return [self.players[0]] + # temporarily set first player as winner high_score = self.players[0].get_victory_points() - winner = self.players[0] - tie = False + winners = [self.players[0]] + # iterate the rest of the players in the game for player in self.players[1:]: score = player.get_victory_points() + + # if this player scored more, + # mark them as winner and high score if score > high_score: high_score = score - winner = player + winners = [player] + # if scores are equal elif score == high_score: - if player.turns < winner.turns: - winner = player - tie = False - elif player.turns == winner.turns: - tie = True - return None if tie else winner - - def get_stats(self): - if winner := self.get_winner(): - logger.info(f"\n{winner} won in {winner.turns} turns!") - else: - logger.info(f"\nGame ended in a tie after {self.players[0].turns} turns") - for player in self.players: - logger.info( - f"\n\nPlayer: {player} \nScore: {player.get_victory_points()} \nDeck: {DeckCounter(player.get_all_cards())}" + # players tie if number of turns is equal + if player.turns == winners[0].turns: + winners.append(player) + + # otherwise, player with fewer turns wins + elif player.turns < winners[0].turns: + winners = [player] + + # note + # we can compare to just the first player in winners, + # because if there were multiple players in winners + # they would have equal score and turns + + return winners + + def summerize_game(self) -> GameResult: + """ + Called at the end of the game, + this creates a summary of the game + + """ + + player_summaries = [] + winners = self.get_winners() + + for order, player in enumerate(self.players): + + # player won + if player in winners and len(winners) == 1: + result = GameOutcome.win + + # player tied + elif player in winners: + result = GameOutcome.tie + + # player lost + else: + result = GameOutcome.loss + + summary = PlayerSummary( + player=player, + result=result, + score=player.get_victory_points(), + turns=player.turns, + shuffles=player.shuffles, + turn_order=order + 1, + deck=DeckCounter(player.get_all_cards()), ) + player_summaries.append(summary) + + game_result = GameResult( + game=self, + turns=winners[0].turns, + winners=winners, + player_summaries=player_summaries, + ) + return game_result diff --git a/pyminion/players.py b/pyminion/players.py index 4f593d2..0976f51 100644 --- a/pyminion/players.py +++ b/pyminion/players.py @@ -65,6 +65,7 @@ def reset(self): """ self.turns = 0 + self.shuffles = 0 self.deck.cards = [] self.discard_pile.cards = [] self.hand.cards = [] diff --git a/pyminion/result.py b/pyminion/result.py new file mode 100644 index 0000000..8f74f06 --- /dev/null +++ b/pyminion/result.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, List + +if TYPE_CHECKING: + from pyminion.core import DeckCounter + from pyminion.game import Game + from pyminion.players import Player + + +class GameOutcome(Enum): + """ + player can either lose, tie, or win the game + + """ + + loss = -1 + tie = 0 + win = 1 + + +@dataclass +class PlayerSummary: + """ + holds summary of a player from a complete game + + """ + + player: "Player" + result: GameOutcome + score: int + turns: int + shuffles: int + turn_order: int + deck: "DeckCounter" + + def __repr__(self): + + order_format = "None" + if self.turn_order == 1: + order_format = "1st" + + elif self.turn_order == 2: + order_format = "2nd" + + elif self.turn_order == 3: + order_format = "3rd" + + elif self.turn_order == 4: + order_format = "4th" + + player = f"Player: {self.player.player_id}" + result = f"Result: {self.result.name}" + score = f"Score: {self.score}" + turns = f"Turns: {self.turns}" + shuffles = f"Shuffles: {self.shuffles}" + order = f"Turn Order: {order_format}" + deck = f"Deck: {self.deck}" + + return f"{player}\n{result}\n{score}\n{turns}\n{shuffles}\n{order}\n{deck}" + + +@dataclass +class GameResult: + """ + holds summary of a complete game + + """ + + game: "Game" + winners: List["Player"] + turns: int + player_summaries: List[PlayerSummary] + + def __repr__(self): + if len(self.winners) == 1: + result = f"{self.winners[0]} won in {self.turns} turns" + else: + result = f"{[w for w in self.winners]} tied after {self.turns} turns" + + format_summaries = "" + for s in self.player_summaries: + format_summaries += f"\n{s}" + + return f"Game Result: {result}{format_summaries}" + + +@dataclass +class PlayerSimulatorResult: + player: "Player" + wins: int + losses: int + ties: int + + +@dataclass +class SimulatorResult: + """ + holds summary of game outcomes over a simulation + + """ + + iterations: int + game_results: List[GameResult] + player_results: List[PlayerSimulatorResult] + + def __repr__(self): + title = f"ran {self.iterations} games" + + format_results = "" + for result in self.player_results: + format_results += f"\n{result.player.player_id} won {result.wins}, lost {result.losses}, tied {result.ties}" + + return f"Simulation Result: {title}{format_results}" diff --git a/pyminion/simulator.py b/pyminion/simulator.py index b0ae802..79dcd07 100644 --- a/pyminion/simulator.py +++ b/pyminion/simulator.py @@ -1,10 +1,10 @@ import copy import logging -from typing import List, Union +from typing import Dict, List -from pyminion.bots.bot import Bot from pyminion.game import Game -from pyminion.players import Human, Player +from pyminion.players import Player +from pyminion.result import GameResult, PlayerSimulatorResult, SimulatorResult logger = logging.getLogger() @@ -30,26 +30,52 @@ class Simulator: def __init__(self, game: Game, iterations: int = 100): self.game = game self.iterations = iterations - self.winners: List[Union[Player, Human, Bot]] + self.results: List[GameResult] = [] - def run(self) -> None: + def run(self) -> SimulatorResult: logger.info(f"Simulating {self.iterations} games...") - winners = [] - for i in range(self.iterations): + for _ in range(self.iterations): game = copy.copy((self.game)) - game.play() - winner = game.get_winner() - winners.append(winner if winner else "tie") - self.winners = winners - self.get_stats() - - def get_stats(self) -> None: - logger.info(f"\nSimulation of {self.iterations} games") + result = game.play() + self.results.append(result) + + return self.get_sim_result() + + def get_sim_result(self) -> SimulatorResult: + + # make temp hashmap to store player sim results + player_results: Dict[Player, PlayerSimulatorResult] = {} + + # initialize each player result with default values for player in self.game.players: - logger.info( - f"{player.player_id} wins: {get_percent(self.winners.count(player), self.iterations)}% ({self.winners.count(player)})" + player_results[player] = PlayerSimulatorResult( + player=player, wins=0, losses=0, ties=0 ) - logger.info( - f"Ties: {get_percent(self.winners.count('tie'), self.iterations)}% ({self.winners.count('tie')})\n" + # iterate through each simulated game to determine win record + for result in self.results: + + # single player wins + if len(result.winners) == 1: + player_results[result.winners[0]].wins += 1 + + # multiple players tie + else: + for player in result.winners: + player_results[player].ties += 1 + + # rest of players are losers + for player in self.game.players: + if player not in result.winners: + player_results[player].losses += 1 + + player_results_final: List[PlayerSimulatorResult] = list( + player_results.values() + ) + + sim_result = SimulatorResult( + iterations=self.iterations, + game_results=self.results, + player_results=player_results_final, ) + return sim_result diff --git a/tests/test_core/test_game.py b/tests/test_core/test_game.py index 8b0d528..16cf873 100644 --- a/tests/test_core/test_game.py +++ b/tests/test_core/test_game.py @@ -1,7 +1,9 @@ import pytest + from pyminion.core import Card, Supply, Trash from pyminion.exceptions import InvalidGameSetup, InvalidPlayerCount -from pyminion.expansions.base import base_set, duchy, estate, gold, province, smithy +from pyminion.expansions.base import (base_set, duchy, estate, gold, province, + smithy) from pyminion.game import Game from pyminion.players import Human @@ -86,7 +88,7 @@ def test_game_is_over_false(game: Game): def test_game_is_over_true_provinces(game: Game): # Single player game ony has 5 provinces - for i in range(4): + for _ in range(4): game.supply.gain_card(card=province) assert not game.is_over() game.supply.gain_card(card=province) @@ -94,13 +96,13 @@ def test_game_is_over_true_provinces(game: Game): def test_game_is_over_true_three_piles(game: Game): - for i in range(5): + for _ in range(5): game.supply.gain_card(card=estate) assert not game.is_over() - for i in range(5): + for _ in range(5): game.supply.gain_card(card=duchy) assert not game.is_over() - for i in range(29): + for _ in range(29): game.supply.gain_card(card=gold) assert not game.is_over() game.supply.gain_card(card=gold) @@ -108,14 +110,20 @@ def test_game_is_over_true_three_piles(game: Game): def test_game_tie(multiplayer_game: Game): - assert multiplayer_game.get_winner() is None + # if equal score and equal turns, players tie + assert multiplayer_game.get_winners() == [ + multiplayer_game.players[0], + multiplayer_game.players[1], + ] def test_game_win(multiplayer_game: Game): + # player with more points wins multiplayer_game.players[0].deck.add(estate) - assert multiplayer_game.get_winner() == multiplayer_game.players[0] + assert multiplayer_game.get_winners() == [multiplayer_game.players[0]] def test_game_win_turns(multiplayer_game: Game): + # if equal score, player with less turns wins multiplayer_game.players[1].turns += 1 - assert multiplayer_game.get_winner() == multiplayer_game.players[0] + assert multiplayer_game.get_winners() == [multiplayer_game.players[0]] diff --git a/tests/test_players/test_bots/test_bot_game.py b/tests/test_players/test_bots/test_bot_game.py index 55e3094..c2a2958 100644 --- a/tests/test_players/test_bots/test_bot_game.py +++ b/tests/test_players/test_bots/test_bot_game.py @@ -3,15 +3,15 @@ from pyminion.game import Game -def test_game_1_player_play(bm_bot: BigMoney): +def test_game_single_player_play(bm_bot: BigMoney): + # single player always wins by default game = Game( players=[bm_bot], expansions=[base_set], kingdom_cards=[smithy], - use_logger=False, ) game.play() - assert game.get_winner() == bm_bot + assert game.get_winners() == [bm_bot] def test_game_2_player_play(bm_bot: BigMoney): @@ -19,10 +19,9 @@ def test_game_2_player_play(bm_bot: BigMoney): players=[bm_bot, bm_bot], expansions=[base_set], kingdom_cards=[smithy], - use_logger=False, ) game.play() - game.get_winner() + assert len(game.get_winners()) >= 1 def test_game_2_player_with_actions(): @@ -31,7 +30,6 @@ def test_game_2_player_with_actions(): players=[bot, bot], expansions=[base_set], kingdom_cards=[smithy], - use_logger=False, ) game.play() - game.get_winner() + assert len(game.get_winners()) >= 1