Coverage for zombie_nomnom/engine/game.py: 100%

98 statements  

« prev     ^ index     » next       coverage.py v7.6.8, created at 2024-12-05 01:14 +0000

1from copy import deepcopy 

2import operator 

3from typing import Callable 

4 

5 

6from zombie_nomnom.models import DieBag 

7from zombie_nomnom.models.dice import Die 

8 

9from .models import DieRecipe, Player, RoundState 

10from .commands import Command 

11 

12 

13def bag_from_recipes(dice_recipes: list[DieRecipe]): 

14 """Function that can be used to create a bag_function from a list of dice recipes. 

15 

16 **Parameters** 

17 - dice_recipes (`list[DieRecipe]`): the list of recipes to create the dice in the bag. 

18 

19 **Returns** 

20 - `Callable[[], DieBag]`: The new closure that creates bags by referencing the dice_recipes array. 

21 

22 **Raises** 

23 - `ValueError`: When give no recipes. Must have recipes to be able to create a bag that is non-empty. 

24 """ 

25 if not dice_recipes: 

26 raise ValueError("Need recipes to build bag from.") 

27 

28 def _bag_function(): 

29 dice = [] 

30 for recipe in dice_recipes: 

31 dice.extend( 

32 Die( 

33 faces=deepcopy(recipe.faces), 

34 ) 

35 for _ in range(recipe.amount) 

36 ) 

37 return DieBag( 

38 dice=dice, 

39 ) 

40 

41 return _bag_function 

42 

43 

44class ZombieDieGame: 

45 """Instance of the zombie dice that that manages a bag of dice that will be used to coordinate how the game is played. 

46 

47 **Parameters** 

48 - players (`list[PlayerScore]`): players in the game 

49 - commands (`list[tuple[Command, RoundState]]`, optional): previous commands that have been run before in the game. Defaults to `[]` 

50 - bag_function (`Callable[[], DieBag]`, optional): function that will generate a `zombie_nomnom.DieBag` that will be used in the round transitions. Defaults to `zombie_nomnom.DieBag.standard_bag` 

51 - score_threshold (`int`, optional): the score threshold that will trigger the end game. Defaults to `13` 

52 - current_player (`int | None` optional): the index in the player array to represent the current player. Defaults to `None` 

53 - first_winning_player (`int | None` optional): the index in the player array that represents the first player to meet or exceed the score threshold. Defaults to `None` 

54 - game_over (`bool`, optional): marks whether or not game is over. Defaults to `False` 

55 - round (`RoundState | None`, optional): the current round of the game being played. Defaults to a new instance that is created for the first player in the player array. 

56 - bag_recipes (`list[DieRecipe]`): 

57 

58 **Raises** 

59 - `ValueError`: When there is not enough players to play a game. 

60 """ 

61 

62 players: list[Player] 

63 """Players that are in the game.""" 

64 commands: list[tuple[Command, RoundState]] 

65 """Commands that have been processed in the game and the round state they were in before it started.""" 

66 bag_function: Callable[[], DieBag] 

67 """Function that we use when we need to create a new bag for a round.""" 

68 round: RoundState | None 

69 """Current round that we are on.""" 

70 current_player: int | None 

71 """Index of player in the players array who's turn it currently is.""" 

72 first_winning_player: int | None 

73 """Index of player who first exceeded or matched the `score_threshold`.""" 

74 game_over: bool 

75 """Marker for when the game is over.""" 

76 score_threshold: int 

77 """Threshold required for a player to start the end game.""" 

78 bag_recipes: list[DieRecipe] 

79 

80 def __init__( 

81 self, 

82 players: list[str | Player], 

83 commands: list[tuple[Command, RoundState]] | None = None, 

84 bag_function: Callable[[], DieBag] | None = None, 

85 score_threshold: int = 13, 

86 current_player: int | None = None, 

87 first_winning_player: int | None = None, 

88 game_over: bool = False, 

89 round: RoundState | None = None, 

90 bag_recipes: list[DieRecipe] | None = None, 

91 ) -> None: 

92 if len(players) == 0: 

93 raise ValueError("Not enough players for the game we need at least one.") 

94 

95 self.commands = list(commands) if commands else [] 

96 self.players = [ 

97 ( 

98 Player(name=name_or_score) 

99 if isinstance(name_or_score, str) 

100 else name_or_score 

101 ) 

102 for name_or_score in players 

103 ] 

104 if not bag_recipes: 

105 self.bag_function = bag_function or DieBag.standard_bag 

106 self.bag_recipes = [] 

107 else: 

108 self.bag_function = bag_from_recipes(bag_recipes) 

109 self.bag_recipes = bag_recipes 

110 self.score_threshold = score_threshold 

111 

112 self.round = round 

113 self.current_player = current_player 

114 self.first_winning_player = first_winning_player 

115 self.game_over = game_over 

116 

117 if self.round is None and self.current_player is None: 

118 self.next_round() 

119 

120 @property 

121 def winner(self) -> Player: 

122 """The player with the highest score in the players array. On Ties uses the player with the lowest index. 

123 

124 **Returns** 

125 - `Player`: The winning player 

126 """ 

127 return max(self.players, key=operator.attrgetter("total_brains")) 

128 

129 def reset_players(self): 

130 """Resets the game state so that players scores are set to zero and the current_player is reset to `None` as well as the first_winning_player""" 

131 self.players = [player.reset() for player in self.players] 

132 self.current_player = None 

133 self.first_winning_player = None 

134 

135 def reset_game(self): 

136 """Resets the game state by resetting the players, clearing commands, and transitioning to the next round which will be the first players round.""" 

137 self.reset_players() 

138 self.commands = [] 

139 self.next_round() 

140 

141 def next_round(self): 

142 """Transitions the current player index point to the next players turn and then sets the round field with that player and a new bag to play the round.""" 

143 if self.current_player is not None and self.round: 

144 self.players[self.current_player] = self.round.player 

145 

146 if self.current_player is None: 

147 self.current_player = 0 

148 elif self.current_player + 1 < len(self.players): 

149 self.current_player = self.current_player + 1 

150 else: 

151 self.current_player = 0 

152 self.round = RoundState( 

153 bag=self.bag_function(), 

154 player=self.players[self.current_player], 

155 ended=False, 

156 ) 

157 

158 def check_for_game_over(self) -> bool: 

159 """Checks if game is over and sets the game_over field.""" 

160 if not self.round.ended: 

161 return # Still not done with their turns. 

162 game_over = False 

163 # GAME IS OVER WHEN THE LAST PLAYER IN A ROUND TAKES THERE TURN 

164 # I.E. IF SOMEONE MEETS THRESHOLD AND LAST PLAYER HAS HAD A TURN 

165 if len(self.players) == 1 and self.winner.total_brains >= self.score_threshold: 

166 game_over = True 

167 

168 if self.first_winning_player is None: 

169 if self.players[self.current_player].total_brains >= self.score_threshold: 

170 self.first_winning_player = self.current_player 

171 else: 

172 if ( 

173 self.first_winning_player == 0 

174 and self.current_player == len(self.players) - 1 

175 ): 

176 game_over = True 

177 elif ( 

178 self.first_winning_player > self.current_player 

179 and self.first_winning_player - self.current_player == 1 

180 ): 

181 game_over = True 

182 

183 self.game_over = game_over 

184 

185 def update_player(self): 

186 """Updates the player in the players array with the instance that is currently on the round field for the current player index.""" 

187 self.players[self.current_player] = self.round.player 

188 

189 def process_command(self, command: Command) -> RoundState: 

190 """Applies the given command to the active round and transitions to the next round if the current round is over. 

191 

192 **Parameters** 

193 - command (`Command`): command that will modify the round state. 

194 

195 **Raises** 

196 - `ValueError`: When trying to process a command when the game is already over. 

197 

198 **Returns** 

199 - `RoundState`: The round information that happened due to the command. 

200 """ 

201 if self.game_over: 

202 raise ValueError("Cannot command an ended game please reset game.") 

203 

204 self.commands.append((command, self.round)) 

205 

206 resulting_round = command.execute(self.round) 

207 self.round = resulting_round 

208 if self.round.ended: 

209 self.update_player() 

210 self.check_for_game_over() 

211 self.next_round() 

212 return resulting_round