Coverage for app/pycardgame/src/presets.py: 100.0%

139 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 

19from typing import List, Literal 

20 

21from .. import ( 

22 CardMeta, 

23 DeckMeta, 

24 GenericCard, 

25 GenericDeck, 

26 GenericGame, 

27 GenericPlayer, 

28) 

29 

30T_UnoRanks = Literal["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "Skip", 

31 "Reverse", "Draw Two", "Wild", "Wild Draw Four"] 

32T_UnoSuits = Literal["Red", "Green", "Blue", "Yellow", "Wild"] 

33 

34 

35class UnoCard( 

36 GenericCard[T_UnoRanks, T_UnoSuits], 

37 metaclass=CardMeta, 

38 rank_type=T_UnoRanks, 

39 suit_type=T_UnoSuits 

40): 

41 __slots__ = ("wild",) 

42 

43 def __init__(self, rank, suit): 

44 super().__init__(rank, suit, False) # type: ignore 

45 

46 self.wild = False 

47 

48 def is_wild(self): 

49 return self.wild 

50 

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

52 pass 

53 

54 def __str__(self): 

55 if self.get_rank() in ["Wild", "Wild Draw Four"]: 

56 return f"{self.get_rank()}" 

57 return f"{self.get_suit()} {self.get_rank()}" 

58 

59 

60class NumberCard(UnoCard, metaclass=CardMeta, rank_type=T_UnoRanks, 

61 suit_type=T_UnoSuits): 

62 def __init__(self, rank, suit): 

63 super().__init__(rank, suit) 

64 self.wild = False 

65 

66 def effect(self, game, player, *args): 

67 pass 

68 

69 

70class DrawTwoCard(UnoCard, metaclass=CardMeta, rank_type=T_UnoRanks, 

71 suit_type=T_UnoSuits): 

72 def __init__(self, suit): 

73 super().__init__("Draw Two", suit) 

74 self.wild = False 

75 

76 def effect(self, game, player, *args): 

77 if isinstance(game, UnoGame): 

78 game.draw_count += 2 # Add 2 to the draw count 

79 

80 

81class SkipCard(UnoCard, metaclass=CardMeta, rank_type=T_UnoRanks, 

82 suit_type=T_UnoSuits): 

83 def __init__(self, suit): 

84 super().__init__("Skip", suit) 

85 self.wild = False 

86 

87 def effect(self, game, player, *args): 

88 game.next_player() 

89 

90 

91class ReverseCard(UnoCard, metaclass=CardMeta, rank_type=T_UnoRanks, 

92 suit_type=T_UnoSuits): 

93 def __init__(self, suit): 

94 super().__init__("Reverse", suit) 

95 self.wild = False 

96 

97 def effect(self, game, player, *args): 

98 game.reverse_direction() 

99 

100 

101class WildCard(UnoCard, metaclass=CardMeta, rank_type=T_UnoRanks, 

102 suit_type=T_UnoSuits): 

103 def __init__(self): 

104 super().__init__("Wild", "Wild") 

105 self.wild = True 

106 

107 def effect(self, game, player, *args): 

108 if args and args[0] is not None: 

109 self.change_suit(args[0]) 

110 self.wild = False 

111 else: 

112 raise ValueError("A new suit must be provided for Wild card.") 

113 game.discard_cards(self) 

114 

115 

116class WildDrawFourCard(UnoCard, metaclass=CardMeta, rank_type=T_UnoRanks, 

117 suit_type=T_UnoSuits): 

118 def __init__(self): 

119 super().__init__("Wild Draw Four", "Wild") 

120 self.wild = True 

121 

122 def effect(self, game, player, *args): 

123 if args and args[0] is not None: 

124 self.change_suit(args[0]) 

125 self.wild = False 

126 else: 

127 raise ValueError( 

128 "A new suit must be provided for Wild Draw Four card.") 

129 game.draw_cards(game.get_next_player(), 4) 

130 game.next_player() 

131 

132 

133class UnoDeck( 

134 GenericDeck[UnoCard], 

135 metaclass=DeckMeta, 

136 card_type=UnoCard 

137): 

138 def __init__(self, cards=None): 

139 super().__init__() 

140 

141 colors: List[T_UnoSuits] = ["Red", "Green", "Blue", # type: ignore 

142 "Yellow"] 

143 numbers: List[T_UnoRanks] = (["0"] # type: ignore 

144 + [str(i) for i in range(1, 10)] * 2) 

145 

146 # Create the deck with the specialized card types 

147 card_list = [ 

148 # Create Number Cards (0-9) 

149 NumberCard(rank, suit) for suit in colors for rank in numbers 

150 ] + [ 

151 # Create DrawTwo Cards 

152 DrawTwoCard(suit) for suit in colors for _ in range(2) 

153 ] + [ 

154 # Create Skip Cards 

155 SkipCard(suit) for suit in colors for _ in range(2) 

156 ] + [ 

157 # Create Reverse Cards 

158 ReverseCard(suit) for suit in colors for _ in range(2) 

159 ] + [ 

160 # Add Wild Cards 

161 WildCard() for _ in range(4) 

162 ] + [ 

163 # Add Wild Draw Four Cards 

164 WildDrawFourCard() for _ in range(4) 

165 ] 

166 

167 self.cards = cards if cards is not None else card_list 

168 

169 def __str__(self): 

170 return f"UNO Deck with {len(self.cards)} cards." 

171 

172 def __repr__(self): 

173 return f"{self.__class__.__name__}(cards={self.cards!r})" 

174 

175 

176class UnoPlayer(GenericPlayer[UnoCard]): 

177 __slots__ = ("uno",) 

178 

179 def __init__(self, name, hand=None): 

180 super().__init__(name, hand, 0) 

181 self.uno = False 

182 

183 def call_uno(self): 

184 if len(self.hand) == 1: 

185 self.uno = True 

186 return self.uno 

187 

188 def reset_uno(self): 

189 self.uno = False 

190 return self 

191 

192 def __repr__(self): 

193 return (f"{self.__class__.__name__}({self.name!r}, " 

194 f"hand={self.hand!r}, uno={self.uno!r})") 

195 

196 

197class UnoGame(GenericGame[UnoCard]): 

198 def __init__(self, *players, draw_pile=None, discard_pile=None, 

199 hand_size=7): 

200 super().__init__(UnoCard, UnoDeck, draw_pile, discard_pile, None, 

201 hand_size, 0, True, *players) 

202 self.draw_pile = draw_pile if draw_pile else UnoDeck().shuffle() 

203 self.draw_count = 0 # Track accumulated draw count 

204 self.game_ended = False 

205 

206 def check_valid_play(self, card1, card2=None): 

207 if card2 is None: 

208 card2 = self.get_top_card() 

209 

210 if card1 is None or card2 is None: 

211 return False 

212 

213 # Only Draw Two cards can be stacked on top of each other 

214 if self.draw_count > 0 and card1.get_rank() != "Draw Two": 

215 return False 

216 

217 if card1.is_wild(): 

218 return True 

219 return card1.rank == card2.rank or card1.suit == card2.suit 

220 

221 def get_next_player(self): 

222 return self.players[ 

223 (self.current_player_index + self.direction) % len(self.players)] 

224 

225 def start_game(self): 

226 self.deal_initial_cards() 

227 self.discard_pile.add(self.draw_pile.draw()) 

228 return self 

229 

230 def draw_instead_of_play(self, player=None): 

231 player = player or self.get_current_player() 

232 

233 if self.draw_count > 0: 

234 drawn_cards = self.draw_cards(player, self.draw_count) 

235 self.draw_count = 0 

236 else: 

237 drawn_cards = self.draw_cards(player, 1) 

238 

239 return drawn_cards 

240 

241 def determine_winner(self): 

242 for player in self.players: 

243 if len(player) == 0: 

244 return player 

245 return None 

246 

247 def end_game(self, export=None): # type: ignore 

248 winner = self.determine_winner() 

249 if winner is not None: 

250 print(f"{winner.name} wins the game!") 

251 else: 

252 print("Game ended without a winner.") 

253 

254 self.game_ended = True 

255 

256 self.draw_pile.clear() 

257 self.discard_pile.clear() 

258 for player in self.players: 

259 player.hand.clear() 

260 

261 self.players.clear() 

262 

263 # TODO: Export game statistics to a file or database 

264 # self.export_statistics(path=export) 

265 

266 print("Game resources have been cleared and the game is now closed.") 

267 

268 return winner 

269 

270 def __str__(self): 

271 direction = "Clockwise" if self.direction == 1 else "Counter-clockwise" 

272 return ("UNO Game\n" 

273 f"Current Player: {self.get_current_player().name}\n" 

274 f"Draw Pile: {len(self.draw_pile)} card(s)\n" 

275 f"Discard Pile: {len(self.discard_pile)} card(s)\n" 

276 f"Direction: {direction}\n" 

277 f"Top Card: {self.get_top_card()}\n" 

278 "Players:\n" + "\n".join( 

279 [f" - {player.name}: {len(player)} card(s)" for player in 

280 self.players])) 

281 

282 def __repr__(self): 

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

284 f"players={self.players!r}, " 

285 f"draw_pile={self.draw_pile!r}, " 

286 f"discard_pile={self.discard_pile!r}, " 

287 f"hand_size={self.hand_size!r}, " 

288 f"current_player_index={self.current_player_index!r}, " 

289 f"direction={self.direction!r})")