kaggle-environments 1.20.1__py3-none-any.whl → 1.21.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of kaggle-environments might be problematic. Click here for more details.

Files changed (60) hide show
  1. kaggle_environments/__init__.py +2 -2
  2. kaggle_environments/envs/cabt/cabt.js +8 -8
  3. kaggle_environments/envs/cabt/cg/cg.dll +0 -0
  4. kaggle_environments/envs/cabt/cg/libcg.so +0 -0
  5. kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker.js +52 -28
  6. kaggle_environments/envs/{open_spiel/open_spiel.py → open_spiel_env/open_spiel_env.py} +37 -1
  7. kaggle_environments/envs/{open_spiel/test_open_spiel.py → open_spiel_env/test_open_spiel_env.py} +65 -1
  8. kaggle_environments/envs/werewolf/GAME_RULE.md +75 -0
  9. kaggle_environments/envs/werewolf/__init__.py +0 -0
  10. kaggle_environments/envs/werewolf/game/__init__.py +0 -0
  11. kaggle_environments/envs/werewolf/game/actions.py +268 -0
  12. kaggle_environments/envs/werewolf/game/base.py +115 -0
  13. kaggle_environments/envs/werewolf/game/consts.py +156 -0
  14. kaggle_environments/envs/werewolf/game/engine.py +580 -0
  15. kaggle_environments/envs/werewolf/game/night_elimination_manager.py +101 -0
  16. kaggle_environments/envs/werewolf/game/protocols/__init__.py +4 -0
  17. kaggle_environments/envs/werewolf/game/protocols/base.py +242 -0
  18. kaggle_environments/envs/werewolf/game/protocols/bid.py +248 -0
  19. kaggle_environments/envs/werewolf/game/protocols/chat.py +467 -0
  20. kaggle_environments/envs/werewolf/game/protocols/factory.py +59 -0
  21. kaggle_environments/envs/werewolf/game/protocols/vote.py +471 -0
  22. kaggle_environments/envs/werewolf/game/records.py +334 -0
  23. kaggle_environments/envs/werewolf/game/roles.py +326 -0
  24. kaggle_environments/envs/werewolf/game/states.py +214 -0
  25. kaggle_environments/envs/werewolf/game/test_actions.py +45 -0
  26. kaggle_environments/envs/werewolf/test_werewolf.py +161 -0
  27. kaggle_environments/envs/werewolf/test_werewolf_deterministic.py +211 -0
  28. kaggle_environments/envs/werewolf/werewolf.js +4377 -0
  29. kaggle_environments/envs/werewolf/werewolf.json +286 -0
  30. kaggle_environments/envs/werewolf/werewolf.py +602 -0
  31. kaggle_environments/static/player.html +19 -1
  32. kaggle_environments-1.21.0.dist-info/METADATA +30 -0
  33. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/RECORD +55 -36
  34. kaggle_environments/envs/chess/chess.js +0 -4289
  35. kaggle_environments/envs/chess/chess.json +0 -60
  36. kaggle_environments/envs/chess/chess.py +0 -4241
  37. kaggle_environments/envs/chess/test_chess.py +0 -60
  38. kaggle_environments-1.20.1.dist-info/METADATA +0 -315
  39. /kaggle_environments/envs/{open_spiel → open_spiel_env}/__init__.py +0 -0
  40. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/__init__.py +0 -0
  41. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/chess.js +0 -0
  42. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/image_config.jsonl +0 -0
  43. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/openings.jsonl +0 -0
  44. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/__init__.py +0 -0
  45. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four.js +0 -0
  46. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four_proxy.py +0 -0
  47. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/__init__.py +0 -0
  48. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go.js +0 -0
  49. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go_proxy.py +0 -0
  50. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/__init__.py +0 -0
  51. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe.js +0 -0
  52. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe_proxy.py +0 -0
  53. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/__init__.py +0 -0
  54. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker_proxy.py +0 -0
  55. /kaggle_environments/envs/{open_spiel → open_spiel_env}/html_playthrough_generator.py +0 -0
  56. /kaggle_environments/envs/{open_spiel → open_spiel_env}/observation.py +0 -0
  57. /kaggle_environments/envs/{open_spiel → open_spiel_env}/proxy.py +0 -0
  58. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/WHEEL +0 -0
  59. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/entry_points.txt +0 -0
  60. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,580 @@
1
+ import json
2
+ from typing import Dict, List, Protocol, Sequence, Type
3
+
4
+ from .actions import Action, BidAction, ChatAction, VoteAction
5
+ from .base import BaseModerator, PlayerID
6
+ from .consts import DetailedPhase, PhaseDivider, RevealLevel, RoleConst, Team
7
+ from .night_elimination_manager import NightEliminationManager
8
+ from .protocols.base import DiscussionProtocol, VotingProtocol
9
+ from .protocols.chat import BiddingDiscussion
10
+ from .records import (
11
+ DayExileElectedDataEntry,
12
+ EventName,
13
+ GameEndResultsDataEntry,
14
+ GameStartDataEntry,
15
+ GameStartRoleDataEntry,
16
+ RequestWerewolfVotingDataEntry,
17
+ WerewolfNightEliminationElectedDataEntry,
18
+ )
19
+ from .roles import Player
20
+ from .states import GameState
21
+
22
+
23
+ class ActionQueue:
24
+ """A data structure for managing player ids in action specific queues."""
25
+
26
+ def __init__(self):
27
+ self._action_queue: Dict[str, List[PlayerID]] = {}
28
+
29
+ def clear(self):
30
+ self._action_queue = {}
31
+
32
+ def append(self, action_cls: Type[Action], player_id: PlayerID):
33
+ action_type = action_cls.__name__
34
+ self._action_queue.setdefault(action_type, [])
35
+ if player_id in self._action_queue[action_type]:
36
+ raise ValueError(f"player {player_id} is already in the action queue. ")
37
+ self._action_queue[action_type].append(player_id)
38
+
39
+ def extend(self, action_cls: Type[Action], player_ids: Sequence[PlayerID]):
40
+ for player_id in player_ids:
41
+ self.append(action_cls, player_id)
42
+
43
+ def get(self, action_cls: Type[Action]) -> List[str]:
44
+ """return a list of player_id for the selected action."""
45
+ return self._action_queue.get(action_cls.__name__, [])
46
+
47
+ def get_active_player_ids(self) -> List[PlayerID]:
48
+ all_players = set()
49
+ for players in self._action_queue.values():
50
+ all_players.update(players)
51
+ return list(all_players)
52
+
53
+
54
+ def phase_handler(phase: DetailedPhase):
55
+ """Decorator to register a method as a handler for a specific game phase."""
56
+
57
+ def decorator(func):
58
+ setattr(func, "_phase_handler_for", phase)
59
+ return func
60
+
61
+ return decorator
62
+
63
+
64
+ class PhaseHandler(Protocol):
65
+ def __call__(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
66
+ pass
67
+
68
+
69
+ class Moderator(BaseModerator):
70
+ """Drives the finite-state machine for the game."""
71
+
72
+ def __init__(
73
+ self,
74
+ state: GameState,
75
+ discussion: DiscussionProtocol,
76
+ day_voting: VotingProtocol, # Renamed for clarity
77
+ night_voting: VotingProtocol,
78
+ night_elimination_reveal_level: RevealLevel = RevealLevel.ROLE,
79
+ day_exile_reveal_level: RevealLevel = RevealLevel.ROLE,
80
+ ):
81
+ self._state = state
82
+ self.discussion = discussion
83
+ self.day_voting = day_voting
84
+ self.night_voting = night_voting
85
+
86
+ self._night_elimination_reveal_level = night_elimination_reveal_level
87
+ self._day_exile_reveal_level = day_exile_reveal_level
88
+
89
+ self._active_night_roles_queue: List[Player] = []
90
+ self._night_elimination_manager = NightEliminationManager(
91
+ self._state, reveal_level=self._night_elimination_reveal_level
92
+ )
93
+ self._action_queue = ActionQueue()
94
+
95
+ # This is for registering role specific event handling
96
+ self._register_player_handlers()
97
+
98
+ # below is the state transition function table
99
+ # each transition function has the signature tr_func(actions: List[Action]) where the input is a list of actions
100
+ # with the length the same as the number of agents
101
+ self.detailed_phase = DetailedPhase.NIGHT_START
102
+ self._phase_handlers: Dict[DetailedPhase, PhaseHandler] = {}
103
+ self._register_phase_handlers()
104
+
105
+ self._make_initial_announcements()
106
+
107
+ @property
108
+ def state(self) -> GameState:
109
+ return self._state
110
+
111
+ def _make_initial_announcements(self):
112
+ data = GameStartDataEntry(
113
+ player_ids=[p.id for p in self.state.alive_players()],
114
+ number_of_players=len(self.state.alive_players()),
115
+ role_counts=self.state.alive_player_counts_per_role(),
116
+ team_member_counts=self.state.alive_player_counts_per_team(),
117
+ day_discussion_protocol_name=self.discussion.__class__.__name__,
118
+ day_discussion_display_name=self.discussion.display_name,
119
+ day_discussion_protocol_rule=self.discussion.rule,
120
+ night_werewolf_discussion_protocol_name=self.night_voting.__class__.__name__,
121
+ night_werewolf_discussion_display_name=self.night_voting.display_name,
122
+ night_werewolf_discussion_protocol_rule=self.night_voting.rule,
123
+ day_voting_protocol_name=self.day_voting.__class__.__name__,
124
+ day_voting_display_name=self.day_voting.display_name,
125
+ day_voting_protocol_rule=self.day_voting.rule,
126
+ )
127
+
128
+ role_msg = "\n".join(
129
+ ["The following explain the function of each role."]
130
+ + [
131
+ f" * Role name {role.name.value} - team {role.team.value} - {role.descriptions}"
132
+ for role in self.state.all_unique_roles
133
+ ]
134
+ )
135
+
136
+ if self._day_exile_reveal_level == RevealLevel.ROLE:
137
+ day_exile_reveal_msg = "If a player is exiled in the day, their role will be revealed."
138
+ elif self._day_exile_reveal_level == RevealLevel.TEAM:
139
+ day_exile_reveal_msg = "If a player is exiled in the day, their team will be revealed."
140
+ elif self._day_exile_reveal_level == RevealLevel.NO_REVEAL:
141
+ day_exile_reveal_msg = "If a player is exiled in the day, their team and role will NOT be revealed."
142
+ else:
143
+ raise ValueError(f"Unsupported day_exile_reveal_level = {self._day_exile_reveal_level}.")
144
+
145
+ if self._night_elimination_reveal_level == RevealLevel.ROLE:
146
+ night_elimination_reveal_msg = "If a player is eliminated at night, their role will be revealed."
147
+ elif self._night_elimination_reveal_level == RevealLevel.TEAM:
148
+ night_elimination_reveal_msg = "If a player is eliminated at night, their team will be revealed."
149
+ elif self._night_elimination_reveal_level == RevealLevel.NO_REVEAL:
150
+ night_elimination_reveal_msg = (
151
+ "If a player is eliminated at night, their team and role will NOT be revealed."
152
+ )
153
+ else:
154
+ raise ValueError(f"Unsupported night_elimination_reveal_level = {self._night_elimination_reveal_level}.")
155
+
156
+ description = "\n - ".join(
157
+ [
158
+ "Werewolf game begins.",
159
+ f"**Player Roster:** {data.player_ids}",
160
+ f"**Alive Players:** {data.number_of_players}.",
161
+ f"**Role Counts:** {data.role_counts}.",
162
+ f"**Alive Team Member:** {data.team_member_counts}",
163
+ f"**Day Discussion:** {data.day_discussion_display_name}. {data.day_discussion_protocol_rule}",
164
+ f"**Day Exile Vote:** {data.day_voting_display_name}. {data.day_voting_protocol_rule}",
165
+ f"**Night Werewolf Vote:** {data.night_werewolf_discussion_display_name}. {data.night_werewolf_discussion_protocol_rule}",
166
+ role_msg,
167
+ day_exile_reveal_msg,
168
+ night_elimination_reveal_msg,
169
+ ]
170
+ )
171
+ self.state.push_event(
172
+ description=description, event_name=EventName.MODERATOR_ANNOUNCEMENT, public=True, data=data
173
+ )
174
+ # add role specific announcements
175
+ for player in self.state.alive_players():
176
+ data = GameStartRoleDataEntry(
177
+ player_id=player.id, team=player.role.team, role=player.role.name, rule_of_role=player.role.descriptions
178
+ )
179
+ self.state.push_event(
180
+ description=f'Your player id is "{data.player_id}". Your team is "{data.team}". Your role is "{data.role}".\n'
181
+ f"The rule of your role: {data.rule_of_role}",
182
+ event_name=EventName.GAME_START,
183
+ public=False,
184
+ visible_to=[player.id],
185
+ data=data,
186
+ )
187
+
188
+ def _register_phase_handlers(self):
189
+ """Collects all methods decorated with @phase_handler."""
190
+ for attr_name in dir(self):
191
+ attr = getattr(self, attr_name)
192
+ if callable(attr) and hasattr(attr, "_phase_handler_for"):
193
+ phase = getattr(attr, "_phase_handler_for")
194
+ self._phase_handlers[phase] = attr
195
+
196
+ def _register_player_handlers(self):
197
+ for player in self.state.players:
198
+ for event_name, handlers in player.get_event_handlers(self).items():
199
+ for handler in handlers:
200
+ self.state.register_event_handler(event_name, handler)
201
+
202
+ def request_action(
203
+ self,
204
+ action_cls: Type[Action],
205
+ player_id: PlayerID,
206
+ prompt: str,
207
+ data=None,
208
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
209
+ ):
210
+ """A public method for listeners to add a player to the action queue."""
211
+ self._action_queue.append(action_cls, player_id)
212
+ # Create the corresponding data entry to prompt the player
213
+ self.state.push_event(
214
+ description=prompt, event_name=event_name, public=False, visible_to=[player_id], data=data
215
+ )
216
+
217
+ def confirm_action(self, player_actions: Dict[PlayerID, Action]):
218
+ for action in player_actions.values():
219
+ # moderator confirming the action with players
220
+ action.push_event(state=self.state)
221
+
222
+ def set_next_phase(self, new_detailed_phase: DetailedPhase, add_one_day: bool = False):
223
+ """Note: phase change is not the same as phase start, still need phase start at each block"""
224
+ old_detailed_phase = self.detailed_phase
225
+ self.detailed_phase = new_detailed_phase
226
+ self.state.detailed_phase = new_detailed_phase
227
+ self.state.phase = new_detailed_phase.category
228
+
229
+ if add_one_day:
230
+ self.state.day_count += 1
231
+
232
+ self.state.push_event(
233
+ description=f"Transitioning from {old_detailed_phase} to {new_detailed_phase}.",
234
+ event_name=EventName.PHASE_CHANGE,
235
+ public=False,
236
+ )
237
+
238
+ def get_active_player_ids(self) -> List[PlayerID]:
239
+ return self._action_queue.get_active_player_ids()
240
+
241
+ def record_night_save(self, doctor_id: PlayerID, target_id: PlayerID):
242
+ self._night_elimination_manager.record_save(doctor_id, target_id)
243
+
244
+ def _call_handler(self, player_actions: Dict[PlayerID, Action]):
245
+ current_handler = self._phase_handlers.get(self.detailed_phase)
246
+ if current_handler:
247
+ next_detailed_phase = current_handler(player_actions)
248
+ else:
249
+ raise ValueError(f"Unhandled detailed_phase: {self.detailed_phase}")
250
+ add_one_day = True if next_detailed_phase == DetailedPhase.DAY_START else False
251
+ self.set_next_phase(next_detailed_phase, add_one_day=add_one_day)
252
+
253
+ def advance(self, player_actions: Dict[PlayerID, Action]):
254
+ self.confirm_action(player_actions)
255
+ # Process the incoming actions for the current phase.
256
+ self._call_handler(player_actions)
257
+
258
+ # Loop through automatic state transitions (those that don't need agent actions)
259
+ # This continues until the game is over or requires new agent input.
260
+ # this logic is required since Environments in core.py requires that there are some players being ACTIVE to
261
+ # continue. Otherwise, if all INACTIVE the game is marked done.
262
+ while not self.get_active_player_ids() and not self.is_game_over():
263
+ self._call_handler({})
264
+
265
+ # After all transitions, check for game over.
266
+ if self.is_game_over() and self.detailed_phase != DetailedPhase.GAME_OVER:
267
+ # clear action queue
268
+ self._action_queue.clear()
269
+ self.set_next_phase(DetailedPhase.GAME_OVER)
270
+ self._determine_and_log_winner()
271
+
272
+ @phase_handler(DetailedPhase.NIGHT_START)
273
+ def _handle_night_start(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
274
+ self._action_queue.clear()
275
+ self.state.add_phase_divider(PhaseDivider.NIGHT_START)
276
+ self.state.push_event(
277
+ description=f"Night {self.state.day_count} begins!", event_name=EventName.NIGHT_START, public=True
278
+ )
279
+
280
+ # initialize werewolves voting
281
+ self.state.add_phase_divider(PhaseDivider.NIGHT_VOTE_START)
282
+ alive_werewolves = self.state.alive_players_by_role(RoleConst.WEREWOLF)
283
+ alive_werewolf_ids = list({p.id for p in alive_werewolves})
284
+ potential_targets = self.state.alive_players_by_team(Team.VILLAGERS) # Target non-werewolves
285
+
286
+ data = RequestWerewolfVotingDataEntry(
287
+ valid_targets=[f"{p.id}" for p in potential_targets],
288
+ alive_werewolve_player_ids=[f"{p.id}" for p in alive_werewolves],
289
+ voting_protocol_name=self.night_voting.__class__.__name__,
290
+ voting_protocol_rule=self.night_voting.rule,
291
+ action_json_schema=json.dumps(VoteAction.schema_for_player()),
292
+ )
293
+ self.state.push_event(
294
+ description=f"Wake up Werewolves. Your fellow alive werewolves are: {data.alive_werewolve_player_ids}. "
295
+ f"Choose one target player to eliminate tonight. "
296
+ f"The voting rule ({data.voting_protocol_name}): {data.voting_protocol_rule} "
297
+ f"Who would you like to eliminate tonight? Options: {data.valid_targets}.",
298
+ event_name=EventName.VOTE_REQUEST,
299
+ public=False,
300
+ visible_to=alive_werewolf_ids,
301
+ data=data,
302
+ )
303
+ self.night_voting.begin_voting(
304
+ state=self.state, alive_voters=alive_werewolves, potential_targets=potential_targets
305
+ )
306
+ return DetailedPhase.NIGHT_AWAIT_ACTIONS
307
+
308
+ @phase_handler(DetailedPhase.NIGHT_AWAIT_ACTIONS)
309
+ def _handle_night_await_actions(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
310
+ # Process werewolf votes
311
+ werewolf_voters_expected = self._action_queue.get(VoteAction)
312
+ if werewolf_voters_expected:
313
+ self.night_voting.collect_votes(player_actions, self.state, werewolf_voters_expected)
314
+
315
+ self._action_queue.clear()
316
+
317
+ if not self.night_voting.done():
318
+ next_ww_voters = self.night_voting.get_next_voters()
319
+ self._action_queue.extend(VoteAction, next_ww_voters)
320
+ vote_action_queue = self._action_queue.get(VoteAction)
321
+ alive_werewolves_still_to_vote = [
322
+ p for p in self.state.alive_players_by_role(RoleConst.WEREWOLF) if p.id in vote_action_queue
323
+ ]
324
+ if alive_werewolves_still_to_vote:
325
+ for ww_voter in alive_werewolves_still_to_vote:
326
+ prompt = self.night_voting.get_voting_prompt(self.state, ww_voter.id)
327
+ self.state.push_event(
328
+ description=prompt,
329
+ event_name=EventName.VOTE_REQUEST,
330
+ public=False,
331
+ visible_to=[ww_voter.id],
332
+ visible_in_ui=False,
333
+ )
334
+ return DetailedPhase.NIGHT_AWAIT_ACTIONS
335
+ else:
336
+ return DetailedPhase.NIGHT_CONCLUDE
337
+
338
+ @phase_handler(DetailedPhase.NIGHT_CONCLUDE)
339
+ def _handle_night_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
340
+ werewolf_target_id = self.night_voting.get_elected()
341
+
342
+ data = WerewolfNightEliminationElectedDataEntry(elected_target_player_id=werewolf_target_id)
343
+ self.state.push_event(
344
+ description=f'Werewolves elected to eliminate player "{data.elected_target_player_id}".',
345
+ event_name=EventName.VOTE_RESULT,
346
+ public=False,
347
+ visible_to=[p.id for p in self.state.alive_players_by_team(Team.WEREWOLVES)],
348
+ data=data,
349
+ )
350
+
351
+ self._night_elimination_manager.resolve_elimination(werewolf_target_id)
352
+
353
+ self.night_voting.reset()
354
+ self._night_elimination_manager.reset()
355
+
356
+ self.state.add_phase_divider(PhaseDivider.NIGHT_VOTE_END)
357
+ self.state.add_phase_divider(PhaseDivider.NIGHT_END)
358
+ return DetailedPhase.DAY_START
359
+
360
+ @phase_handler(DetailedPhase.DAY_START)
361
+ def _handle_day_start(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
362
+ self.state.add_phase_divider(PhaseDivider.DAY_START)
363
+ self._action_queue.clear()
364
+ self.night_step = 0 # Reset night step counter
365
+
366
+ self.state.push_event(
367
+ description=f"Day {self.state.day_count} begins.", event_name=EventName.DAY_START, public=True
368
+ )
369
+
370
+ self.state.push_event(
371
+ description=f"Villagers, let's decide who to exile. The discussion rule is: {self.discussion.rule}",
372
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
373
+ public=True,
374
+ data={"discussion_rule": self.discussion.rule},
375
+ )
376
+
377
+ self.state.add_phase_divider(PhaseDivider.DAY_CHAT_START)
378
+ self.discussion.begin(self.state)
379
+
380
+ # Check if the protocol starts with bidding
381
+ if isinstance(self.discussion, BiddingDiscussion):
382
+ return DetailedPhase.DAY_BIDDING_AWAIT
383
+ else:
384
+ return DetailedPhase.DAY_CHAT_AWAIT
385
+
386
+ @phase_handler(DetailedPhase.DAY_BIDDING_AWAIT)
387
+ def _handle_day_bidding_await(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
388
+ current_bidders = self._action_queue.get(BidAction)
389
+ self._action_queue.clear()
390
+
391
+ # The protocol processes bid actions
392
+ self.discussion.process_actions(list(player_actions.values()), current_bidders, self.state)
393
+
394
+ # We need to explicitly check if the bidding sub-phase is over
395
+ # This requires a reference to the bidding protocol within BiddingDiscussion
396
+ if self.discussion.bidding.is_finished(self.state):
397
+ return DetailedPhase.DAY_BIDDING_CONCLUDE
398
+ else:
399
+ # Bidding is not over (e.g., sequential auction), get next bidders
400
+ next_bidders = self.discussion.speakers_for_tick(self.state)
401
+ self._action_queue.extend(BidAction, next_bidders)
402
+ self.discussion.prompt_speakers_for_tick(self.state, next_bidders)
403
+ return DetailedPhase.DAY_BIDDING_AWAIT
404
+
405
+ @phase_handler(DetailedPhase.DAY_BIDDING_CONCLUDE)
406
+ def _handle_day_bidding_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
407
+ self.state.push_event(
408
+ description="Bidding has concluded. The discussion will now begin.",
409
+ event_name=EventName.PHASE_CHANGE,
410
+ public=True,
411
+ )
412
+ self.discussion.bidding.reset()
413
+ return DetailedPhase.DAY_CHAT_AWAIT
414
+
415
+ @phase_handler(DetailedPhase.DAY_CHAT_AWAIT)
416
+ def _handle_day_chat_await(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
417
+ speaker_ids = self._action_queue.get(ChatAction)
418
+ self._action_queue.clear()
419
+ self.discussion.process_actions(list(player_actions.values()), speaker_ids, self.state)
420
+
421
+ if self.discussion.is_discussion_over(self.state):
422
+ return DetailedPhase.DAY_CHAT_CONCLUDE
423
+ else:
424
+ # Discussion is not over. Check if we need to go back to bidding action and phase.
425
+ if isinstance(self.discussion, BiddingDiscussion) and self.discussion.is_bidding_phase():
426
+ return DetailedPhase.DAY_BIDDING_AWAIT
427
+ # Get the next active players (either bidders or the next speaker)
428
+ next_actors = self.discussion.speakers_for_tick(self.state)
429
+ self._action_queue.extend(ChatAction, next_actors)
430
+ self.discussion.prompt_speakers_for_tick(self.state, next_actors)
431
+ return DetailedPhase.DAY_CHAT_AWAIT
432
+
433
+ @phase_handler(DetailedPhase.DAY_CHAT_CONCLUDE)
434
+ def _handle_day_chat_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
435
+ self.state.push_event(
436
+ description="Daytime discussion has concluded. Moving to day vote.",
437
+ event_name=EventName.PHASE_CHANGE,
438
+ public=True,
439
+ )
440
+ self.discussion.reset()
441
+ self.state.add_phase_divider(PhaseDivider.DAY_CHAT_END)
442
+ return DetailedPhase.DAY_VOTING_START
443
+
444
+ @phase_handler(DetailedPhase.DAY_VOTING_START)
445
+ def _handle_day_voting_start(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
446
+ self.state.add_phase_divider(PhaseDivider.DAY_VOTE_START)
447
+ alive_players = self.state.alive_players()
448
+ self.day_voting.begin_voting(self.state, alive_players, alive_players)
449
+ self.state.push_event(
450
+ description="Voting phase begins. We will decide who to exile today."
451
+ f"\nDay voting Rule: {self.day_voting.rule}"
452
+ f"\nCurrent alive players are: {[player.id for player in alive_players]}",
453
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
454
+ public=True,
455
+ data={"voting_rule": self.day_voting.rule},
456
+ )
457
+ return DetailedPhase.DAY_VOTING_AWAIT
458
+
459
+ @phase_handler(DetailedPhase.DAY_VOTING_AWAIT)
460
+ def _handle_day_voting_await(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
461
+ vote_queue = self._action_queue.get(VoteAction)
462
+ self.day_voting.collect_votes(player_actions, self.state, vote_queue)
463
+ self._action_queue.clear() # Clear previous voters
464
+
465
+ if self.day_voting.done():
466
+ return DetailedPhase.DAY_VOTING_CONCLUDE
467
+ else:
468
+ next_voters_ids = self.day_voting.get_next_voters()
469
+ self._action_queue.extend(VoteAction, next_voters_ids)
470
+ if next_voters_ids:
471
+ for voter_id in next_voters_ids:
472
+ player = self.state.get_player_by_id(voter_id)
473
+ if player and player.alive:
474
+ prompt = self.day_voting.get_voting_prompt(self.state, voter_id)
475
+ self.state.push_event(
476
+ description=prompt,
477
+ event_name=EventName.VOTE_REQUEST,
478
+ public=False,
479
+ visible_to=[voter_id],
480
+ visible_in_ui=False,
481
+ )
482
+ return DetailedPhase.DAY_VOTING_AWAIT
483
+
484
+ @phase_handler(DetailedPhase.DAY_VOTING_CONCLUDE)
485
+ def _handle_day_voting_conclude(self, player_actions: Dict[PlayerID, Action]) -> DetailedPhase:
486
+ exiled_player_id = self.day_voting.get_elected()
487
+ if exiled_player_id:
488
+ exiled_player = self.state.get_player_by_id(exiled_player_id)
489
+ if exiled_player:
490
+ self.state.eliminate_player(exiled_player_id)
491
+
492
+ role = None
493
+ team = None
494
+ description = f'Player "{exiled_player_id}" is exiled by vote.'
495
+ if self._day_exile_reveal_level == RevealLevel.ROLE:
496
+ role = exiled_player.role.name
497
+ team = exiled_player.role.team
498
+ description = (
499
+ f'Player "{exiled_player_id}" in team {team} is exiled by vote. The player is a {role}.'
500
+ )
501
+ elif self._day_exile_reveal_level == RevealLevel.TEAM:
502
+ team = exiled_player.role.team
503
+ description = f'Player "{exiled_player_id}" in team {team} is exiled by vote.'
504
+
505
+ data = DayExileElectedDataEntry(
506
+ elected_player_id=exiled_player_id, elected_player_role_name=role, elected_player_team_name=team
507
+ )
508
+ self.state.push_event(description=description, event_name=EventName.ELIMINATION, public=True, data=data)
509
+ else:
510
+ self.state.push_event(
511
+ description="The vote resulted in no exile (e.g., a tie, no majority, or all abstained).",
512
+ event_name=EventName.VOTE_RESULT,
513
+ public=True,
514
+ data={"vote_type": "day_exile", "outcome": "no_exile", "reason": "tie_or_no_majority"},
515
+ )
516
+
517
+ self.day_voting.reset()
518
+ self.state.add_phase_divider(PhaseDivider.DAY_VOTE_END)
519
+ self.state.add_phase_divider(PhaseDivider.DAY_END)
520
+ return DetailedPhase.NIGHT_START
521
+
522
+ def _determine_and_log_winner(self):
523
+ # Check if a GAME_END entry already exists
524
+ game_end_event = self.state.get_event_by_name(EventName.GAME_END)
525
+ if game_end_event:
526
+ return # Winner already logged for this day count
527
+
528
+ wolves = [p for p in self.state.alive_players() if p.role.team == Team.WEREWOLVES]
529
+ villagers = [p for p in self.state.alive_players() if p.role.team == Team.VILLAGERS]
530
+
531
+ if not wolves:
532
+ winner_team = Team.VILLAGERS.value
533
+ winner_message = "Game Over: Villagers Win!"
534
+ reason = "Reason: All werewolves exiled."
535
+ scores = {p.id: 1 for p in self.state.get_players_by_team(team=Team.VILLAGERS)}
536
+ scores.update({p.id: 0 for p in self.state.get_players_by_team(team=Team.WEREWOLVES)})
537
+ winner_ids = [p.id for p in self.state.get_players_by_team(Team.VILLAGERS)]
538
+ loser_ids = [p.id for p in self.state.get_players_by_team(Team.WEREWOLVES)]
539
+ else:
540
+ winner_team = Team.WEREWOLVES.value
541
+ winner_message = "Game Over: Werewolves Win!"
542
+ reason = f"Reason: len(werewolves) >= len(villagers). Final counts: len(werewolves)={len(wolves)}, len(villagers)={len(villagers)})."
543
+ scores = {p.id: 1 for p in self.state.get_players_by_team(team=Team.WEREWOLVES)}
544
+ scores.update({p.id: 0 for p in self.state.get_players_by_team(team=Team.VILLAGERS)})
545
+ loser_ids = [p.id for p in self.state.get_players_by_team(Team.VILLAGERS)]
546
+ winner_ids = [p.id for p in self.state.get_players_by_team(Team.WEREWOLVES)]
547
+
548
+ data = GameEndResultsDataEntry(
549
+ winner_team=winner_team,
550
+ winner_ids=winner_ids,
551
+ loser_ids=loser_ids,
552
+ scores=scores,
553
+ reason=reason,
554
+ last_day=self.state.day_count,
555
+ last_phase=self.state.phase.value,
556
+ survivors_until_last_round_and_role={p.id: p.role.name.value for p in self.state.alive_players()},
557
+ all_players_and_role={p.id: p.role.name.value for p in self.state.players},
558
+ elimination_info=self.state.get_elimination_info(),
559
+ all_players=[p.model_dump() for p in self.state.players],
560
+ )
561
+
562
+ self.state.push_event(
563
+ description=f"{winner_message}\n{reason}\nScores: {scores}\n"
564
+ f"Survivors: {data.survivors_until_last_round_and_role}\n"
565
+ f"All player roles: {data.all_players_and_role}",
566
+ event_name=EventName.GAME_END,
567
+ public=True,
568
+ data=data,
569
+ )
570
+
571
+ def is_game_over(self) -> bool:
572
+ if self.detailed_phase == DetailedPhase.GAME_OVER:
573
+ return True
574
+ wolves = self.state.alive_players_by_team(Team.WEREWOLVES)
575
+ villagers = self.state.alive_players_by_team(Team.VILLAGERS)
576
+ if not wolves and villagers:
577
+ return True
578
+ if wolves and len(wolves) >= len(villagers):
579
+ return True
580
+ return False
@@ -0,0 +1,101 @@
1
+ from typing import Dict, List, Optional
2
+
3
+ from .base import PlayerID
4
+ from .consts import RevealLevel, Team
5
+ from .records import DoctorSaveDataEntry, EventName, WerewolfNightEliminationDataEntry
6
+ from .states import GameState
7
+
8
+
9
+ class NightEliminationManager:
10
+ """
11
+ Manages the state and resolution of nighttime eliminations.
12
+ """
13
+
14
+ def __init__(self, state: GameState, reveal_level: RevealLevel = RevealLevel.ROLE):
15
+ self._state = state
16
+ self._reveal_level = reveal_level
17
+ self._saves: Dict[PlayerID, List[PlayerID]] = {} # Key: target_id, Value: [doctor_id]
18
+
19
+ def reset(self):
20
+ """Clears all recorded actions for the start of a new night."""
21
+ self._saves.clear()
22
+
23
+ def record_save(self, doctor_id: PlayerID, target_id: PlayerID):
24
+ """Records a save action from a Doctor."""
25
+ self._saves.setdefault(target_id, []).append(doctor_id)
26
+
27
+ def resolve_elimination(self, werewolf_target_id: Optional[PlayerID]):
28
+ """
29
+ Resolves the werewolf attack against any saves, eliminates a player
30
+ if necessary, and pushes the resulting events to the game state.
31
+ """
32
+ if not werewolf_target_id:
33
+ self._state.push_event(
34
+ description="Last night, the werewolves did not reach a consensus (or no valid target was chosen)."
35
+ " No one was eliminated by werewolves.",
36
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
37
+ public=False,
38
+ visible_to=self._state.get_players_by_team(Team.WEREWOLVES),
39
+ )
40
+ self._state.push_event(
41
+ description="Last night, No one was eliminated.",
42
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
43
+ public=True,
44
+ )
45
+ return
46
+
47
+ target_player = self._state.get_player_by_id(werewolf_target_id)
48
+ if not target_player:
49
+ self._state.push_event(
50
+ description=f'Last night, werewolves targeted player "{werewolf_target_id}", but this player '
51
+ f"could not be found. No one was eliminated by werewolves.",
52
+ event_name=EventName.ERROR,
53
+ public=False,
54
+ visible_to=self._state.get_players_by_team(Team.WEREWOLVES),
55
+ )
56
+ self._state.push_event(
57
+ description="Last night, no one was eliminated.",
58
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
59
+ public=True,
60
+ )
61
+ return
62
+
63
+ if werewolf_target_id in self._saves:
64
+ # The player was saved.
65
+ saving_doctor_ids = self._saves[werewolf_target_id]
66
+ save_data = DoctorSaveDataEntry(saved_player_id=werewolf_target_id)
67
+ self._state.push_event(
68
+ description=f'Your heal on player "{werewolf_target_id}" was successful!',
69
+ event_name=EventName.HEAL_RESULT,
70
+ public=False,
71
+ data=save_data,
72
+ visible_to=saving_doctor_ids,
73
+ )
74
+ self._state.push_event(
75
+ description="Last night, no one was eliminated.",
76
+ event_name=EventName.MODERATOR_ANNOUNCEMENT,
77
+ public=True,
78
+ )
79
+ else:
80
+ # The player is eliminated.
81
+ original_role_name = target_player.role.name
82
+ self._state.eliminate_player(werewolf_target_id)
83
+
84
+ team = None
85
+ role = None
86
+ descriptions = [f'Last night, player "{werewolf_target_id}" was eliminated by werewolves.']
87
+ if self._reveal_level == RevealLevel.ROLE:
88
+ team = target_player.role.team
89
+ role = target_player.role.name
90
+ descriptions.append(f'Their role was a "{original_role_name}".')
91
+ elif self._reveal_level == RevealLevel.TEAM:
92
+ team = target_player.role.team
93
+ descriptions.append(f'Their team was "{team}".')
94
+
95
+ data = WerewolfNightEliminationDataEntry(
96
+ eliminated_player_id=werewolf_target_id,
97
+ eliminated_player_role_name=role,
98
+ eliminated_player_team_name=team,
99
+ )
100
+ description = " ".join(descriptions)
101
+ self._state.push_event(description=description, event_name=EventName.ELIMINATION, public=True, data=data)
@@ -0,0 +1,4 @@
1
+ # The line below register the protocols
2
+ from . import bid, chat, factory, vote
3
+
4
+ __all__ = ['bid', 'chat', 'factory', 'vote']