Coverage for app/pycardgame/src/base.py: 100.0%
367 statements
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-07 20:57 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2025-04-07 20:57 +0000
1# PyCardGame - A base library for creating card games in Python
2# Copyright (C) 2025 Popa-42
3#
4# This program is free software: you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation, either version 3 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program. If not, see <https://www.gnu.org/licenses/>.
17from __future__ import annotations
19import random
20from abc import ABC, ABCMeta, abstractmethod
21from typing import Generic, get_args, MutableSequence, Type, TypeVar
23_RankT = TypeVar("_RankT")
24_SuitT = TypeVar("_SuitT")
27class CardMeta(ABCMeta):
28 def __new__(cls, name, bases, class_dict, rank_type, suit_type):
29 class_dict["RANKS"] = list(get_args(rank_type))
30 class_dict["SUITS"] = list(get_args(suit_type))
31 return super().__new__(cls, name, bases, class_dict)
34class GenericCard(ABC, Generic[_RankT, _SuitT]):
35 __slots__ = ("rank", "suit", "trump")
37 RANKS: MutableSequence[_RankT] = []
38 SUITS: MutableSequence[_SuitT] = []
40 def __init__(self, rank, suit, trump=False):
41 self.rank = None
42 self.suit = None
44 if rank is not None:
45 self.change_rank(rank)
46 if suit is not None:
47 self.change_suit(suit)
49 self.trump = trump
51 @staticmethod
52 def _set_value(value, values_list, value_name):
53 if not isinstance(value, int):
54 if value is None:
55 return None
56 elif value not in values_list:
57 raise ValueError(f"Invalid {value_name} name: {value}")
58 value = values_list.index(value)
59 else:
60 if value < 0 or value >= len(values_list):
61 raise ValueError(f"Invalid {value_name} index: {value}")
62 return value
64 @abstractmethod
65 def effect(self, game, player, *args): # pragma: no cover
66 pass
68 def get_rank(self, as_index=False):
69 if self.rank is None:
70 return None
71 return self.rank if as_index else self.__class__.RANKS[self.rank]
73 def change_rank(self, rank):
74 self.rank = self._set_value(rank, self.__class__.RANKS, "rank")
75 return self
77 def get_suit(self, as_index=False):
78 if self.suit is None:
79 return None
80 return self.suit if as_index else self.__class__.SUITS[self.suit]
82 def change_suit(self, suit):
83 self.suit = self._set_value(suit, self.__class__.SUITS, "suit")
84 return self
86 def is_trump(self):
87 return self.trump
89 def set_trump(self, trump):
90 if not isinstance(trump, bool):
91 raise TypeError("Trump must be a boolean value")
92 self.trump = trump
93 return self
95 def __copy__(self):
96 return self.__class__(rank=self.rank, suit=self.suit, trump=self.trump)
98 def __str__(self):
99 rank_str = str(self.get_rank(as_index=False))
100 suit_str = str(self.get_suit(as_index=False))
101 return f"{suit_str} {rank_str}{' (trump)' if self.trump else ''}"
103 def __repr__(self):
104 return (f"{self.__class__.__name__}(rank={self.rank!r}, "
105 f"suit={self.suit!r}{', trump=True' if self.trump else ''})")
107 def __lt__(self, other):
108 if self.trump and not other.trump:
109 return False
110 if not self.trump and other.trump:
111 return True
112 # Defined cards are always greater than None
113 suit1 = self.suit if self.suit is not None else float("-inf")
114 suit2 = other.suit if other.suit is not None else float("-inf")
115 rank1 = self.rank if self.rank is not None else float("-inf")
116 rank2 = other.rank if other.rank is not None else float("-inf")
117 return (suit1, rank1) < (suit2, rank2)
119 def __eq__(self, other):
120 if not isinstance(other, self.__class__):
121 return NotImplemented
122 return (self.suit == other.suit and self.rank == other.rank and
123 self.trump == other.trump)
125 def __ne__(self, other):
126 if not isinstance(other, self.__class__):
127 return NotImplemented
128 return not self.__eq__(other)
130 def __gt__(self, other):
131 return not self.__lt__(other) and not self.__eq__(other)
133 def __le__(self, other):
134 return not self.__gt__(other)
136 def __ge__(self, other):
137 return not self.__lt__(other)
140_CardT = TypeVar("_CardT", bound=GenericCard)
143class DeckMeta(ABCMeta):
144 def __new__(cls, name, bases, class_dict, card_type):
145 class_dict["_card_type"] = card_type
146 return super().__new__(cls, name, bases, class_dict)
149class GenericDeck(ABC, Generic[_CardT]):
150 _card_type: Type[_CardT]
151 __hash__ = None # type: ignore # Mutable type, so hash is not defined
153 def __init__(self, cards=None):
154 if cards is None:
155 self.cards = self.reset().get_cards()
156 else:
157 self.cards = list(cards)
159 def reset(self):
160 self.cards = [self._card_type(rank, suit)
161 for suit in range(len(self._card_type.SUITS))
162 for rank in range(len(self._card_type.RANKS))]
163 return self.sort()
165 def count(self, card):
166 if isinstance(card, self._card_type):
167 return self.cards.count(card)
168 elif isinstance(card, str):
169 if card in self._card_type.RANKS:
170 return sum(1 for c in self.cards if c.get_rank() == card)
171 elif card in self._card_type.SUITS:
172 return sum(1 for c in self.cards if c.get_suit() == card)
173 else:
174 raise ValueError(
175 "Invalid card name: must be a rank or suit name")
176 else:
177 raise TypeError(
178 "Invalid card type: must be a Card object, a suit, or a rank")
180 def sort(self, by="suit"):
181 if by == "rank":
182 self.cards.sort(key=lambda c: (
183 not c.trump, c.rank if c.rank is not None else -1,
184 c.suit if c.suit is not None else -1))
185 elif by == "suit":
186 self.cards.sort()
187 else:
188 raise ValueError("Invalid sort key: must be 'rank' or 'suit'")
189 return self
191 def shuffle(self, seed=None):
192 if seed is not None:
193 random.seed(seed)
194 random.shuffle(self.cards)
195 return self
197 def draw(self, n=1):
198 if n < 1 or n > len(self.cards):
199 raise ValueError(f"Cannot draw {n} cards: number of cards to draw "
200 f"must be between 1 and {len(self.cards)}")
201 return self.cards.pop(0) if n == 1 else [self.cards.pop(0) for _ in (
202 range(n))]
204 def add(self, *cards, to_top=False):
205 if not all(isinstance(card, self._card_type) for card in cards):
206 raise TypeError("Invalid card type: must be a Card object")
207 if to_top:
208 self.cards = list(cards) + self.cards
209 else:
210 self.cards.extend(cards)
211 return self
213 def remove(self, *cards):
214 if not all(isinstance(card, self._card_type) for card in cards):
215 raise TypeError("Invalid card type: must be a Card object")
216 for card in cards:
217 self.cards.remove(card)
218 return self
220 def clear(self):
221 self.cards.clear()
222 return self
224 def get_index(self, card):
225 if not isinstance(card, self._card_type):
226 raise TypeError("Invalid card type: must be a Card object")
227 return [i for i, c in enumerate(self.cards) if c == card]
229 def get_cards(self):
230 return self.cards
232 def get_top_card(self):
233 return self.cards[0] if self.cards else None
235 def __str__(self):
236 deck_string = f"Deck of {len(self)} cards."
237 top_card = f" Top card: {self[0]}" if self.cards else ""
238 return deck_string + top_card
240 def __repr__(self):
241 return (f"{self.__class__.__name__}("
242 f"card_type={self._card_type!r}, "
243 f"cards={self.cards!r})")
245 def __eq__(self, other):
246 if not isinstance(other, self.__class__):
247 return NotImplemented
248 return self.cards == other.cards
250 def __ne__(self, other):
251 if not isinstance(other, self.__class__):
252 return NotImplemented
253 return not self.__eq__(other)
255 def __copy__(self):
256 return self.__class__(cards=self.cards.copy())
258 def __getitem__(self, key):
259 return self.cards[key]
261 def __len__(self):
262 return len(self.cards)
264 def __iter__(self):
265 return iter(self.cards)
267 def __contains__(self, item):
268 if not isinstance(item, self._card_type):
269 return False
270 return item in self.cards
272 def __bool__(self):
273 return bool(self.cards)
276class GenericPlayer(ABC, Generic[_CardT]):
277 __slots__ = ("name", "hand", "score")
279 def __init__(self, name, hand=None, score=0):
280 self.name = name
281 self.hand = hand or []
282 self.score = score
284 def add_cards(self, *cards):
285 self.hand.extend(cards)
287 def remove_cards(self, *cards):
288 for card in cards:
289 self.hand.remove(card)
290 return self
292 def play_cards(self, *cards):
293 if not cards:
294 cards = self.hand
295 for card in cards:
296 self.hand.remove(card)
297 return list(cards)
299 def get_hand(self):
300 return self.hand
302 def get_score(self):
303 return self.score
305 def set_score(self, score):
306 self.score = score
307 return self
309 def get_name(self):
310 return self.name
312 def set_name(self, name):
313 self.name = name
314 return self
316 def __getitem__(self, key):
317 return self.hand[key]
319 def __str__(self):
320 return f"Player {self.name} ({len(self.hand)} card(s)):\n" + "\n".join(
321 f" - {card}" for card in self.hand)
323 def __repr__(self):
324 return (f"{self.__class__.__name__}({self.name!r}, hand={self.hand!r},"
325 f" score={self.score!r})")
327 def __eq__(self, other):
328 if not isinstance(other, self.__class__):
329 return NotImplemented
330 return (self.score == other.score and self.hand == other.hand and
331 self.name == other.name)
333 def __ne__(self, other):
334 if not isinstance(other, self.__class__):
335 return NotImplemented
336 return not self.__eq__(other)
338 def __lt__(self, other):
339 return self.score < other.score
341 def __le__(self, other):
342 return self.score <= other.score
344 def __gt__(self, other):
345 return self.score > other.score
347 def __ge__(self, other):
348 return self.score >= other.score
350 def __bool__(self):
351 return bool(self.hand) and self.score >= 0
353 def __iter__(self):
354 return iter(self.hand)
356 def __len__(self):
357 return len(self.hand)
360class GenericGame(ABC, Generic[_CardT]):
361 def __init__(self, card_type, deck_type, draw_pile=None, discard_pile=None,
362 trump=None, hand_size=4, starting_player_index=0,
363 do_not_shuffle=False, *players):
364 self._card_type = card_type
365 self._deck_type = deck_type
367 if trump is not None and trump not in self._card_type.SUITS:
368 raise ValueError(f"Invalid suit for trump: {trump}")
370 self.draw_pile = draw_pile if draw_pile is not None \
371 else self._deck_type()
372 if not do_not_shuffle:
373 self.draw_pile.shuffle()
375 self.discard_pile = discard_pile or self._deck_type(cards=[])
377 self.hand_size = hand_size
379 self.players = list(players) if players else []
381 self.trump = None
382 if trump is not None:
383 self.set_trump(trump)
384 self.apply_trump()
386 start_idx = starting_player_index
387 if start_idx != 0: # 0 is a valid index
388 if start_idx < 0 or start_idx >= len(self.players):
389 raise ValueError(f"Invalid starting player index: {start_idx} "
390 f"> {len(self.players)} {self.players}")
391 self.current_player_index = start_idx
393 self.direction = 1 # 1 for clockwise, -1 for counter-clockwise
395 @abstractmethod
396 def check_valid_play(self, card1, card2): # pragma: no cover
397 pass
399 @abstractmethod
400 def start_game(self): # pragma: no cover
401 pass
403 @abstractmethod
404 def end_game(self): # pragma: no cover
405 pass
407 def discard_cards(self, *cards):
408 self.discard_pile.add(*cards, to_top=True)
409 return self
411 def get_discard_pile(self):
412 return self.discard_pile
414 def get_top_card(self):
415 return self.discard_pile.get_top_card()
417 def reshuffle_discard_pile(self):
418 if len(self.draw_pile) == 0:
419 self.draw_pile.add(*self.discard_pile.get_cards())
420 self.discard_pile.clear()
421 self.draw_pile.shuffle()
422 return self
424 def draw_cards(self, player=None, n=1):
425 if len(self.draw_pile) >= n:
426 if player is None:
427 player = self.get_current_player()
428 drawn = self.draw_pile.draw(n)
429 if not isinstance(drawn, list):
430 drawn = [drawn]
431 player.add_cards(*drawn)
432 return drawn
433 if len(self.draw_pile) == 0:
434 return self.reshuffle_discard_pile().draw_cards(player, n)
435 raise ValueError("Not enough cards in the draw pile.")
437 def deal_initial_cards(self, *players):
438 players_to_deal = players or self.players
439 for player in players_to_deal:
440 cards_needed = max(0, self.hand_size - len(player.hand))
441 if cards_needed > 0:
442 player.add_cards(*self.draw_pile.draw(cards_needed))
443 return self
445 def add_players(self, *players):
446 self.players.extend(players)
447 return self
449 def remove_players(self, *players):
450 for player in players:
451 self.players.remove(player)
452 return self
454 def deal(self, num_cards=1, *players):
455 players = players or self.players
456 for player in players:
457 player.add_cards(*self.draw_pile.draw(num_cards))
458 return self
460 def play_card(self, card, player=None, *args):
461 top_card = self.discard_pile.get_top_card()
463 if top_card is None:
464 return False
466 if self.check_valid_play(card, top_card):
467 if not player:
468 player = self.get_current_player()
470 self.discard_cards(card)
471 player.play_cards(card)
473 card.effect(self, player, *args)
475 return True
476 return False
478 def shuffle(self):
479 self.draw_pile.shuffle()
480 return self
482 def get_trump(self):
483 return self.trump
485 def set_trump(self, suit):
486 if suit not in self._card_type.SUITS:
487 raise ValueError(f"Invalid suit for trump: {suit}")
488 self.trump = suit
489 return self
491 def apply_trump(self):
492 for deck in ([self.draw_pile.get_cards(), self.discard_pile.get_cards()]
493 + [player.hand for player in self.players]):
494 for card in deck:
495 card.set_trump(card.get_suit() == self.trump)
496 return self
498 def change_trump(self, suit):
499 if suit not in self._card_type.SUITS:
500 raise ValueError(f"Invalid suit for trump: {suit}")
501 self.set_trump(suit)
502 self.apply_trump()
503 return self
505 def get_current_player(self):
506 return self.players[self.current_player_index]
508 def set_current_player(self, player):
509 if isinstance(player, int):
510 if player < 0 or player >= len(self.players):
511 raise ValueError("Invalid player index")
512 self.current_player_index = player
513 elif isinstance(player, GenericPlayer):
514 self.current_player_index = self.players.index(player)
515 else:
516 raise TypeError(
517 "Invalid player type: must be an integer or a Player object")
518 return self
520 def get_players(self):
521 return self.players
523 def next_player(self):
524 self.reshuffle_discard_pile()
526 new_index = (self.current_player_index + self.direction) % len(
527 self.players)
528 self.set_current_player(new_index)
529 return self
531 def reverse_direction(self):
532 self.direction *= -1
533 return self
535 def get_draw_pile(self):
536 return self.draw_pile
538 def set_draw_pile(self, deck):
539 self.draw_pile = deck
540 return self
542 def __str__(self):
543 return f"Game of {len(self.players)} players"
545 def __repr__(self):
546 return (f"{self.__class__.__name__}("
547 f"card_type={self._card_type!r}, "
548 f"deck_type={self._deck_type!r}, "
549 f"draw_pile={self.draw_pile!r}, "
550 f"discard_pile={self.discard_pile!r}, "
551 f"trump={self.trump!r}, "
552 f"hand_size={self.hand_size!r}, "
553 f"starting_player_index={self.current_player_index!r}, "
554 f"*{self.players!r}")