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
« 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
6from zombie_nomnom.models import DieBag
7from zombie_nomnom.models.dice import Die
9from .models import DieRecipe, Player, RoundState
10from .commands import Command
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.
16 **Parameters**
17 - dice_recipes (`list[DieRecipe]`): the list of recipes to create the dice in the bag.
19 **Returns**
20 - `Callable[[], DieBag]`: The new closure that creates bags by referencing the dice_recipes array.
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.")
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 )
41 return _bag_function
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.
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]`):
58 **Raises**
59 - `ValueError`: When there is not enough players to play a game.
60 """
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]
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.")
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
112 self.round = round
113 self.current_player = current_player
114 self.first_winning_player = first_winning_player
115 self.game_over = game_over
117 if self.round is None and self.current_player is None:
118 self.next_round()
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.
124 **Returns**
125 - `Player`: The winning player
126 """
127 return max(self.players, key=operator.attrgetter("total_brains"))
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
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()
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
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 )
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
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
183 self.game_over = game_over
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
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.
192 **Parameters**
193 - command (`Command`): command that will modify the round state.
195 **Raises**
196 - `ValueError`: When trying to process a command when the game is already over.
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.")
204 self.commands.append((command, self.round))
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