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

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/>. 

16 

17from __future__ import annotations 

18 

19import random 

20from abc import ABC, ABCMeta, abstractmethod 

21from typing import Generic, get_args, MutableSequence, Type, TypeVar 

22 

23_RankT = TypeVar("_RankT") 

24_SuitT = TypeVar("_SuitT") 

25 

26 

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) 

32 

33 

34class GenericCard(ABC, Generic[_RankT, _SuitT]): 

35 __slots__ = ("rank", "suit", "trump") 

36 

37 RANKS: MutableSequence[_RankT] = [] 

38 SUITS: MutableSequence[_SuitT] = [] 

39 

40 def __init__(self, rank, suit, trump=False): 

41 self.rank = None 

42 self.suit = None 

43 

44 if rank is not None: 

45 self.change_rank(rank) 

46 if suit is not None: 

47 self.change_suit(suit) 

48 

49 self.trump = trump 

50 

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 

63 

64 @abstractmethod 

65 def effect(self, game, player, *args): # pragma: no cover 

66 pass 

67 

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] 

72 

73 def change_rank(self, rank): 

74 self.rank = self._set_value(rank, self.__class__.RANKS, "rank") 

75 return self 

76 

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] 

81 

82 def change_suit(self, suit): 

83 self.suit = self._set_value(suit, self.__class__.SUITS, "suit") 

84 return self 

85 

86 def is_trump(self): 

87 return self.trump 

88 

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 

94 

95 def __copy__(self): 

96 return self.__class__(rank=self.rank, suit=self.suit, trump=self.trump) 

97 

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 ''}" 

102 

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 ''})") 

106 

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) 

118 

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) 

124 

125 def __ne__(self, other): 

126 if not isinstance(other, self.__class__): 

127 return NotImplemented 

128 return not self.__eq__(other) 

129 

130 def __gt__(self, other): 

131 return not self.__lt__(other) and not self.__eq__(other) 

132 

133 def __le__(self, other): 

134 return not self.__gt__(other) 

135 

136 def __ge__(self, other): 

137 return not self.__lt__(other) 

138 

139 

140_CardT = TypeVar("_CardT", bound=GenericCard) 

141 

142 

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) 

147 

148 

149class GenericDeck(ABC, Generic[_CardT]): 

150 _card_type: Type[_CardT] 

151 __hash__ = None # type: ignore # Mutable type, so hash is not defined 

152 

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) 

158 

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() 

164 

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") 

179 

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 

190 

191 def shuffle(self, seed=None): 

192 if seed is not None: 

193 random.seed(seed) 

194 random.shuffle(self.cards) 

195 return self 

196 

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))] 

203 

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 

212 

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 

219 

220 def clear(self): 

221 self.cards.clear() 

222 return self 

223 

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] 

228 

229 def get_cards(self): 

230 return self.cards 

231 

232 def get_top_card(self): 

233 return self.cards[0] if self.cards else None 

234 

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 

239 

240 def __repr__(self): 

241 return (f"{self.__class__.__name__}(" 

242 f"card_type={self._card_type!r}, " 

243 f"cards={self.cards!r})") 

244 

245 def __eq__(self, other): 

246 if not isinstance(other, self.__class__): 

247 return NotImplemented 

248 return self.cards == other.cards 

249 

250 def __ne__(self, other): 

251 if not isinstance(other, self.__class__): 

252 return NotImplemented 

253 return not self.__eq__(other) 

254 

255 def __copy__(self): 

256 return self.__class__(cards=self.cards.copy()) 

257 

258 def __getitem__(self, key): 

259 return self.cards[key] 

260 

261 def __len__(self): 

262 return len(self.cards) 

263 

264 def __iter__(self): 

265 return iter(self.cards) 

266 

267 def __contains__(self, item): 

268 if not isinstance(item, self._card_type): 

269 return False 

270 return item in self.cards 

271 

272 def __bool__(self): 

273 return bool(self.cards) 

274 

275 

276class GenericPlayer(ABC, Generic[_CardT]): 

277 __slots__ = ("name", "hand", "score") 

278 

279 def __init__(self, name, hand=None, score=0): 

280 self.name = name 

281 self.hand = hand or [] 

282 self.score = score 

283 

284 def add_cards(self, *cards): 

285 self.hand.extend(cards) 

286 

287 def remove_cards(self, *cards): 

288 for card in cards: 

289 self.hand.remove(card) 

290 return self 

291 

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) 

298 

299 def get_hand(self): 

300 return self.hand 

301 

302 def get_score(self): 

303 return self.score 

304 

305 def set_score(self, score): 

306 self.score = score 

307 return self 

308 

309 def get_name(self): 

310 return self.name 

311 

312 def set_name(self, name): 

313 self.name = name 

314 return self 

315 

316 def __getitem__(self, key): 

317 return self.hand[key] 

318 

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) 

322 

323 def __repr__(self): 

324 return (f"{self.__class__.__name__}({self.name!r}, hand={self.hand!r}," 

325 f" score={self.score!r})") 

326 

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) 

332 

333 def __ne__(self, other): 

334 if not isinstance(other, self.__class__): 

335 return NotImplemented 

336 return not self.__eq__(other) 

337 

338 def __lt__(self, other): 

339 return self.score < other.score 

340 

341 def __le__(self, other): 

342 return self.score <= other.score 

343 

344 def __gt__(self, other): 

345 return self.score > other.score 

346 

347 def __ge__(self, other): 

348 return self.score >= other.score 

349 

350 def __bool__(self): 

351 return bool(self.hand) and self.score >= 0 

352 

353 def __iter__(self): 

354 return iter(self.hand) 

355 

356 def __len__(self): 

357 return len(self.hand) 

358 

359 

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 

366 

367 if trump is not None and trump not in self._card_type.SUITS: 

368 raise ValueError(f"Invalid suit for trump: {trump}") 

369 

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() 

374 

375 self.discard_pile = discard_pile or self._deck_type(cards=[]) 

376 

377 self.hand_size = hand_size 

378 

379 self.players = list(players) if players else [] 

380 

381 self.trump = None 

382 if trump is not None: 

383 self.set_trump(trump) 

384 self.apply_trump() 

385 

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 

392 

393 self.direction = 1 # 1 for clockwise, -1 for counter-clockwise 

394 

395 @abstractmethod 

396 def check_valid_play(self, card1, card2): # pragma: no cover 

397 pass 

398 

399 @abstractmethod 

400 def start_game(self): # pragma: no cover 

401 pass 

402 

403 @abstractmethod 

404 def end_game(self): # pragma: no cover 

405 pass 

406 

407 def discard_cards(self, *cards): 

408 self.discard_pile.add(*cards, to_top=True) 

409 return self 

410 

411 def get_discard_pile(self): 

412 return self.discard_pile 

413 

414 def get_top_card(self): 

415 return self.discard_pile.get_top_card() 

416 

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 

423 

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.") 

436 

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 

444 

445 def add_players(self, *players): 

446 self.players.extend(players) 

447 return self 

448 

449 def remove_players(self, *players): 

450 for player in players: 

451 self.players.remove(player) 

452 return self 

453 

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 

459 

460 def play_card(self, card, player=None, *args): 

461 top_card = self.discard_pile.get_top_card() 

462 

463 if top_card is None: 

464 return False 

465 

466 if self.check_valid_play(card, top_card): 

467 if not player: 

468 player = self.get_current_player() 

469 

470 self.discard_cards(card) 

471 player.play_cards(card) 

472 

473 card.effect(self, player, *args) 

474 

475 return True 

476 return False 

477 

478 def shuffle(self): 

479 self.draw_pile.shuffle() 

480 return self 

481 

482 def get_trump(self): 

483 return self.trump 

484 

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 

490 

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 

497 

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 

504 

505 def get_current_player(self): 

506 return self.players[self.current_player_index] 

507 

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 

519 

520 def get_players(self): 

521 return self.players 

522 

523 def next_player(self): 

524 self.reshuffle_discard_pile() 

525 

526 new_index = (self.current_player_index + self.direction) % len( 

527 self.players) 

528 self.set_current_player(new_index) 

529 return self 

530 

531 def reverse_direction(self): 

532 self.direction *= -1 

533 return self 

534 

535 def get_draw_pile(self): 

536 return self.draw_pile 

537 

538 def set_draw_pile(self, deck): 

539 self.draw_pile = deck 

540 return self 

541 

542 def __str__(self): 

543 return f"Game of {len(self.players)} players" 

544 

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}")