diff --git a/.gitignore b/.gitignore index b6e4761..bfd99ba 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Jetbrains +.idea diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/simple_agent.py b/agents/simple_agent.py new file mode 100644 index 0000000..68bc364 --- /dev/null +++ b/agents/simple_agent.py @@ -0,0 +1,11 @@ +from game.agent import Agent +from game.table import Table +from game.player import Player + + +class SimpleAgent(Agent): + def __init__(self): + super().__init__(Player()) + + def make_move(self, table: Table) -> None: + table.try_move(self, [sorted(self.player.hand)[0]]) diff --git a/game/__init__.py b/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/agent.py b/game/agent.py new file mode 100644 index 0000000..c2402f0 --- /dev/null +++ b/game/agent.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import List, TYPE_CHECKING + +from game.player import Player + +if TYPE_CHECKING: + from game.card import Card + from game.table import Table + + +class Agent: + def __init__(self, player: Player): + self.player: Player = player + + def make_move(self, table: Table) -> None: + """ + This function is called when the agent should make a move. + table.make_move() should be called, this makes the move and returns a reward. + """ + pass + + def receive_event(self, move, event) -> None: + pass + + def get_preferred_card_order(self, table: Table) -> List[Card]: + """ + Function used by President to exchange cards at the beginning of a round. Most wanted card should be in front. + + """ + return sorted(table.deck.card_stack, reverse=True) diff --git a/game/card.py b/game/card.py new file mode 100644 index 0000000..4658a73 --- /dev/null +++ b/game/card.py @@ -0,0 +1,64 @@ +import enum + + +class Color(enum.Enum): + Black = 1 + Red = 2 + + def __lt__(self, other): + return self.value < other.value + + def __gt__(self, other): + return self.value > other.value + + +class Suit(enum.Enum): + Clubs = 1 + Diamonds = 2 + Spades = 3 + Hearts = 4 + + def get_color(self): + """Suits have fixed colors""" + return Color.Red if self.value % 2 == 0 else Color.Black + + +class Card: + """"A card in the deck""" + + def __init__(self, value: int, suit: Suit, name: str): + self.value = value + self.suit = suit + self.name = name + + def get_value(self) -> int: + return self.value + + def get_color(self) -> Color: + return self.suit.get_color() + + def get_suit(self) -> Suit: + return self.suit + + def get_name(self) -> str: + return self.name + + def __eq__(self, other): + return self.value == other.value and self.suit.get_color() == other.suit.get_color() + + def __ne__(self, other): + return not self == other + + def __lt__(self, other): + return self.value < other.value or ( + self.value == other.value and self.suit.get_color() < other.suit.get_color()) + + def __le__(self, other): + return self < other or self == other + + def __gt__(self, other): + return self.value > other.value or ( + self.value == other.value and self.suit.get_color() > other.suit.get_color()) + + def __ge__(self, other): + return self > other or self == other diff --git a/game/deck.py b/game/deck.py new file mode 100644 index 0000000..2d60edc --- /dev/null +++ b/game/deck.py @@ -0,0 +1,24 @@ +from random import shuffle +from typing import List + +from game.card import Card, Suit +from game.settings import CARD_VALUES + + +class Deck: + """Just a collection of cards, with some functions to ease things. No game specific implementations.""" + + def __init__(self): + self.card_stack: List[Card] = [] + self.reset_cards_stack() + + def reset_cards_stack(self) -> None: + """ + Reset the card stack. Clear all cards and add a fresh (shuffled) set. + """ + self.card_stack.clear() + for symbol in CARD_VALUES: + for suit in Suit: + self.card_stack.append( + Card(CARD_VALUES[symbol], suit, str(suit.get_color()) + " " + symbol + " of " + str(suit))) + shuffle(self.card_stack) diff --git a/game/player.py b/game/player.py new file mode 100644 index 0000000..2d3eee0 --- /dev/null +++ b/game/player.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from itertools import combinations +from typing import List, TYPE_CHECKING + +if TYPE_CHECKING: + from game.card import Card + from game.table import Table + + +class Player: + _player_id: int = 0 + + def __init__(self): + self.hand: List[Card] = [] + self.player_id: int = Player._player_id + Player._player_id += 1 + + def get_all_possible_moves(self, table: Table) -> [[Card]]: + possible_moves = [] + for amount_of_cards in range(len(table.last_move()[0]) if table.last_move() else 1, len(self.hand) + 1): + for potential_move in combinations(self.hand, amount_of_cards): + if table.game.valid_move(list(potential_move)): + possible_moves.append(list(potential_move)) + return possible_moves diff --git a/game/president.py b/game/president.py new file mode 100644 index 0000000..e12912d --- /dev/null +++ b/game/president.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING + +from tqdm import tqdm + +from game.table import Table +from util.cards import get_played_value +from util.iterator import CustomIterator + +if TYPE_CHECKING: + from game.agent import Agent + from game.card import Card + + +class President: + """ + A class containing the game logic. + """ + + def __init__(self, agents: List[Agent]): + self.agents: List[Agent] = agents + self.passed_agents: Dict[Agent, bool] = { + agent: False for agent in self.agents + } + self.agent_finish_order: List[Agent] = [] + self.agent_iterator: CustomIterator = CustomIterator(agents) + self.table = Table(self) + + def play(self, games: int, rounds: int) -> None: + """ + Start the game. Play a certain amount of games each consisting of a certain amount of rounds. + """ + progress = tqdm(total=games * rounds) + + for g in range(games): + for r in range(rounds): + # Update the progress bar + progress.set_description(f"Running round {r} of game {g}") + progress.update() + + # Reset from the previous round + self._reset() + + # If this is not the first round exchange cards + if r != 0: + self._exchange_cards() + self.agent_finish_order = [] + + # Play the round + for agent in self._get_play_order(): + agent.make_move(self.table) + + # If the player finished this round award it by giving it its position. + if len(agent.player.hand) == 0: + self.agent_finish_order.append(agent) + + progress.close() + + def on_move(self, agent: Agent, cards: List[Card]) -> Tuple[int, bool]: + """ + Handle move from Agent, We can be sure the agent can actually play the card. + return (reward, is_final). + """ + if not cards: + # A Pass, disable the player for this round + self.passed_agents[agent] = True + return -5, False # TODO fix reward + + # Previous value should be lower + if self.valid_move(cards): + self.table.do_move(agent, cards) + return 10, False # TODO fix reward + else: + return -10, False # TODO fix reward + + def valid_move(self, cards: List[Card]) -> bool: + last_move: Tuple[List[Card], Agent] = self.table.last_move() + + # If multiple cards are played length should be at least the same. + if cards and last_move and len(cards) < len(last_move[0]): + return False + + # Check that each played card in the trick has the same rank, or if not, it is a 2. + played_value: Optional[int] = get_played_value(cards) + if not played_value or played_value < 0: + return False + last_move_value: int = get_played_value(last_move[0]) if last_move else None + + # Previous value should be lower + return not last_move or last_move_value <= played_value + + def _reset(self) -> None: + """ + - (Re)divide cards + - reset the finish order + - reset the playing table + """ + for i, hand in enumerate(self.table.divide(len(self.agents))): + self.agents[i].player.hand = hand + self.table.reset() + + def _exchange_cards(self) -> None: + # Todo discuss this, but for now only the first and last player trade cards + first: Agent = self.agent_finish_order[0] + last: Agent = self.agent_finish_order[-1] + preferred_cards: List[Card] = first.get_preferred_card_order(self.table) + + # Hand best card from loser to winner + card_index = 0 + while preferred_cards[card_index] not in last.player.hand: + card_index += 1 + + exchange_card: Card = last.player.hand[card_index] + first.player.hand.append(exchange_card) + last.player.hand.remove(exchange_card) + + # Hand lowest card from winner to loser + exchange_card = sorted(first.player.hand)[0] + first.player.hand.remove(exchange_card) + last.player.hand.append(exchange_card) + + def _get_play_order(self) -> Iterator[Agent]: + """ + Return the player order, this is an iterator so this allows for cleaner code in the President class. + """ + # As long as there are 2 unfinished players + while [len(agent.player.hand) > 0 for agent in self.agents].count(True) >= 2: + self.agent_iterator.next() + nr_skips: int = 0 + + while nr_skips <= len(self.agents) and ( + len(self.agent_iterator.get().player.hand) == 0 or self.passed_agents[self.agent_iterator.get()]): + self.agent_iterator.next() + nr_skips += 1 + + if nr_skips > len(self.agents): + # All agents have no cards left + if all(len(agent.player.hand) == 0 for agent in self.agents): + return + # Some player still has a card. Start a new trick + last_agent = self.table.last_move()[1] + + self.table.new_trick() + self.passed_agents = { + agent: False for agent in self.agents + } + + # The player that has made the last move can start in the new trick + while self.agent_iterator.get() != last_agent: + self.agent_iterator.next() + # We found the player, but the loop will call next, so we have to call previous to neutralize this. + self.agent_iterator.previous() + + yield self.agent_iterator.get() + # The unfinished player comes last, add it to the last_played lis + self.agent_finish_order.append(list(filter(lambda x: len(x.player.hand) > 0, self.agents))[0]) diff --git a/game/settings.py b/game/settings.py new file mode 100644 index 0000000..5e98c29 --- /dev/null +++ b/game/settings.py @@ -0,0 +1,16 @@ +# Some settings for easy adjustment later on +CARD_VALUES = { + "Ace": 14, + "2": 15, + "3": 3, + "4": 4, + "5": 5, + "6": 6, + "7": 7, + "8": 8, + "9": 9, + "10": 10, + "Jack": 11, + "Queen": 12, + "King": 13, +} diff --git a/game/table.py b/game/table.py new file mode 100644 index 0000000..8e7cb34 --- /dev/null +++ b/game/table.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from random import shuffle +from typing import List, Tuple, Optional, TYPE_CHECKING + +from game.deck import Deck + +if TYPE_CHECKING: + from game.agent import Agent + from game.card import Card + from game.president import President + + +class Table: + def __init__(self, game: President): + self.game = game + self.current: int = 0 + self.deck = Deck() + self.played_cards: List[Tuple[List[Card], Agent]] = [] + self.discard_pile: List[List[Card]] = [] + + def reset(self) -> None: + """ + Reset the table: + - reset played_cards + - reset discard_pile + """ + self.played_cards.clear() + self.discard_pile.clear() + + def new_trick(self) -> None: + """ + Move the cards from the played_cards to the discard_pile. + """ + self.discard_pile += self.played_cards + self.played_cards.clear() + + def try_move(self, agent: Agent, cards: List[Card]) -> Tuple[int, bool]: + """ + Take a move from an agent, execute the move on the table and give a reward to the agent. + Validate if the move is valid first. + + TODO: discuss this. + TODO: move rewards to settings file. + Reward scheme: + - Invalid move: -10 + - else return game specific reward + + return the reward and if the move is final. + """ + # A pass is a valid move. + if len(cards) != 0: + # WARNING: when playing with 2 decks of cards this is not sufficient. + if not all(card in agent.player.hand for card in cards): + return -10, False + + return self.game.on_move(agent, cards) + + def do_move(self, agent: Agent, cards: List[Card]) -> None: + # The move is valid, the cards can be moved from the players hand to the table. If the play was not a pass. + if cards: + [agent.player.hand.remove(card) for card in cards] + self.played_cards.append((cards, agent)) + + def last_move(self) -> Optional[Tuple[List[Card], Agent]]: + """ + Get the last move + """ + return self.played_cards[-1] if len(self.played_cards) > 0 else None + + def divide(self, nr_players: int) -> List[List[Card]]: + """ + Shuffle and Divide all cards in as there are players, indicated by nr_players + """ + shuffle(self.deck.card_stack) + result = [[] for _ in range(nr_players)] + for i in range(len(self.deck.card_stack)): + result[i % nr_players].append(self.deck.card_stack[i]) + return result diff --git a/main.py b/main.py new file mode 100644 index 0000000..076b1f7 --- /dev/null +++ b/main.py @@ -0,0 +1,10 @@ +from game.president import President +from agents.simple_agent import SimpleAgent + +if __name__ == "__main__": + game = President([ + SimpleAgent() for _ in range(3) + ]) + + # Start the game + game.play(1000, 10) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..29a4d6a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +tqdm==4.51.0 diff --git a/util/__init__.py b/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util/cards.py b/util/cards.py new file mode 100644 index 0000000..d7b34b8 --- /dev/null +++ b/util/cards.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, List + +if TYPE_CHECKING: + from game.card import Card + + +def get_played_value(cards: List[Card]) -> Optional[int]: + """ + Get the value of a stack off cards if they are all the same except for some 2s, -1 if not and None if only 2s + """ + played_value: Optional[int] = None + for card in cards: + if card.value != 2: + if played_value and played_value != card.value: + # Cards do not have the same rank + return -1 + played_value = card.value + return played_value diff --git a/util/iterator.py b/util/iterator.py new file mode 100644 index 0000000..935da30 --- /dev/null +++ b/util/iterator.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import List, Any + + +class CustomIterator: + def __init__(self, collection: List): + self.collection: List = collection + self.index = 0 + + def next(self) -> None: + self.index = (self.index + 1) % len(self.collection) + + def previous(self) -> None: + self.index = (self.index - 1) % len(self.collection) + + def get(self) -> Any: + return self.collection[self.index] + + def __iter__(self): + return self + + def __next__(self): + self.next() + return self.get()