kaggle-environments 1.20.0__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 (59) 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.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/METADATA +9 -4
  33. {kaggle_environments-1.20.0.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/envs/{open_spiel → open_spiel_env}/__init__.py +0 -0
  39. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/__init__.py +0 -0
  40. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/chess.js +0 -0
  41. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/image_config.jsonl +0 -0
  42. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/openings.jsonl +0 -0
  43. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/__init__.py +0 -0
  44. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four.js +0 -0
  45. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four_proxy.py +0 -0
  46. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/__init__.py +0 -0
  47. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go.js +0 -0
  48. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go_proxy.py +0 -0
  49. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/__init__.py +0 -0
  50. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe.js +0 -0
  51. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe_proxy.py +0 -0
  52. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/__init__.py +0 -0
  53. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker_proxy.py +0 -0
  54. /kaggle_environments/envs/{open_spiel → open_spiel_env}/html_playthrough_generator.py +0 -0
  55. /kaggle_environments/envs/{open_spiel → open_spiel_env}/observation.py +0 -0
  56. /kaggle_environments/envs/{open_spiel → open_spiel_env}/proxy.py +0 -0
  57. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/WHEEL +0 -0
  58. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/entry_points.txt +0 -0
  59. {kaggle_environments-1.20.0.dist-info → kaggle_environments-1.21.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,471 @@
1
+ import random
2
+ from collections import Counter, deque
3
+ from typing import Dict, List, Optional, Sequence
4
+
5
+ from kaggle_environments.envs.werewolf.game.actions import Action, NoOpAction, VoteAction
6
+ from kaggle_environments.envs.werewolf.game.base import PlayerID
7
+ from kaggle_environments.envs.werewolf.game.consts import EventName, Phase, StrEnum
8
+ from kaggle_environments.envs.werewolf.game.protocols.base import VotingProtocol
9
+ from kaggle_environments.envs.werewolf.game.records import (
10
+ DayExileVoteDataEntry,
11
+ VoteOrderDataEntry,
12
+ WerewolfNightVoteDataEntry,
13
+ )
14
+ from kaggle_environments.envs.werewolf.game.roles import Player
15
+ from kaggle_environments.envs.werewolf.game.states import GameState
16
+
17
+ from .factory import register_protocol
18
+
19
+
20
+ class TieBreak(StrEnum):
21
+ RANDOM = "random"
22
+ """Randomly select from top ties."""
23
+
24
+ NO_EXILE = "no_elected"
25
+ """Tie result in no one elected."""
26
+
27
+
28
+ ABSTAIN_VOTE = "-1"
29
+
30
+
31
+ class Ballot:
32
+ def __init__(self, tie_selection: TieBreak = TieBreak.RANDOM):
33
+ self._ballots: Dict[PlayerID, PlayerID] = {}
34
+ self._tie_selection = tie_selection
35
+
36
+ def reset(self):
37
+ self._ballots = {}
38
+
39
+ def add_vote(self, voter_id: PlayerID, target_id: PlayerID):
40
+ """Records a vote from a voter for a target."""
41
+ self._ballots[voter_id] = target_id
42
+
43
+ def get_tally(self) -> Counter:
44
+ """Returns a Counter of votes for each target, excluding abstained votes."""
45
+ return Counter(v for v in self._ballots.values() if v is not None and v != ABSTAIN_VOTE)
46
+
47
+ def get_elected(self, potential_targets: List[PlayerID]) -> Optional[PlayerID]:
48
+ """
49
+ Tallies the votes and determines the elected player based on the tie-breaking rule.
50
+ """
51
+ counts = self.get_tally().most_common()
52
+ elected: Optional[PlayerID] = None
53
+
54
+ if not counts:
55
+ # No valid votes were cast.
56
+ if self._tie_selection == TieBreak.RANDOM and potential_targets:
57
+ elected = random.choice(potential_targets)
58
+ # If NO_EXILE, elected remains None.
59
+ else:
60
+ _, top_votes = counts[0]
61
+ top_candidates = [v for v, c in counts if c == top_votes]
62
+
63
+ if len(top_candidates) == 1:
64
+ elected = top_candidates[0]
65
+ else: # It's a tie.
66
+ if self._tie_selection == TieBreak.RANDOM:
67
+ elected = random.choice(top_candidates)
68
+ # If NO_EXILE, elected remains None.
69
+
70
+ return elected
71
+
72
+ def get_all_votes(self) -> Dict[PlayerID, PlayerID]:
73
+ """Returns a copy of all recorded ballots."""
74
+ return self._ballots.copy()
75
+
76
+
77
+ @register_protocol()
78
+ class SimultaneousMajority(VotingProtocol):
79
+ def __init__(self, tie_break=TieBreak.RANDOM):
80
+ self._expected_voters: List[PlayerID] = []
81
+ self._potential_targets: List[PlayerID] = []
82
+ self._current_game_state: Optional[GameState] = None # To store state from begin_voting
83
+ self._elected: Optional[PlayerID] = None
84
+ self._done_tallying = False
85
+ self._tie_break = tie_break
86
+ self._ballot = Ballot(tie_selection=self._tie_break)
87
+
88
+ if tie_break not in TieBreak:
89
+ raise ValueError(f"Invalid tie_break value: {tie_break}. Must be one of {TieBreak}.")
90
+
91
+ def reset(self) -> None:
92
+ self._ballot.reset()
93
+ self._expected_voters = []
94
+ self._potential_targets = []
95
+ self._current_game_state = None
96
+ self._elected = None
97
+ self._done_tallying = False
98
+
99
+ @property
100
+ def display_name(self) -> str:
101
+ return "Simultaneous Majority Voting"
102
+
103
+ @property
104
+ def rule(self) -> str:
105
+ rule = "Player with the most votes is exiled. "
106
+ if self._tie_break == TieBreak.RANDOM:
107
+ rule += (
108
+ "Ties result in random selection amongst the top ties. "
109
+ "If no valid vote available (if all casted abstained votes), "
110
+ "will result in random elimination of one player."
111
+ )
112
+ elif self._tie_break == TieBreak.NO_EXILE:
113
+ rule += "Ties result in no exile."
114
+ return rule
115
+
116
+ def begin_voting(self, state: GameState, alive_voters: Sequence[Player], potential_targets: Sequence[Player]):
117
+ self._ballot.reset()
118
+ # Ensure voters and targets are alive at the start of voting
119
+ self._expected_voters = [p.id for p in alive_voters if p.alive]
120
+ self._potential_targets = [p.id for p in potential_targets if p.alive]
121
+ self._current_game_state = state # Store the game state reference
122
+
123
+ def collect_votes(self, player_actions: Dict[PlayerID, Action], state: GameState, expected_voters: List[PlayerID]):
124
+ for actor_id, action in player_actions.items():
125
+ if actor_id in expected_voters:
126
+ self.collect_vote(action, state)
127
+
128
+ # For any expected voter who didn't act, record an abstain vote.
129
+ all_votes = self._ballot.get_all_votes()
130
+ for player_id in expected_voters:
131
+ if player_id not in all_votes:
132
+ self._ballot.add_vote(player_id, ABSTAIN_VOTE)
133
+
134
+ def collect_vote(self, vote_action: Action, state: GameState):
135
+ actor_player = state.get_player_by_id(vote_action.actor_id)
136
+ if not isinstance(vote_action, VoteAction):
137
+ state.push_event(
138
+ description=f'Invalid vote attempt by player "{vote_action.actor_id}". '
139
+ f"Not a VoteAction; submitted {vote_action.__class__.__name__} instead. "
140
+ f"Cast as abstained vote.",
141
+ event_name=EventName.ERROR,
142
+ public=False,
143
+ visible_to=self._expected_voters,
144
+ data={},
145
+ )
146
+ self._ballot.add_vote(vote_action.actor_id, ABSTAIN_VOTE)
147
+ return
148
+
149
+ if state.phase == Phase.NIGHT:
150
+ data_entry_class = WerewolfNightVoteDataEntry
151
+ else:
152
+ data_entry_class = DayExileVoteDataEntry
153
+
154
+ data = data_entry_class(
155
+ actor_id=vote_action.actor_id,
156
+ target_id=vote_action.target_id,
157
+ reasoning=vote_action.reasoning,
158
+ perceived_threat_level=vote_action.perceived_threat_level,
159
+ action=vote_action,
160
+ )
161
+
162
+ # Voter must be expected and alive at the moment of casting vote
163
+ if actor_player and actor_player.alive and vote_action.actor_id in self._expected_voters:
164
+ # Prevent re-voting
165
+ if vote_action.actor_id in self._ballot.get_all_votes():
166
+ state.push_event(
167
+ description=f'Invalid vote attempt by "{vote_action.actor_id}", already voted.',
168
+ event_name=EventName.ERROR,
169
+ public=False,
170
+ visible_to=self._expected_voters,
171
+ data=data,
172
+ )
173
+ return
174
+
175
+ if vote_action.target_id in self._potential_targets:
176
+ self._ballot.add_vote(vote_action.actor_id, vote_action.target_id)
177
+
178
+ # Determine DataEntry type based on game phase
179
+ state.push_event(
180
+ description=f'Player "{data.actor_id}" voted to eliminate "{data.target_id}". ',
181
+ event_name=EventName.VOTE_ACTION,
182
+ public=False,
183
+ visible_to=self._expected_voters,
184
+ data=data,
185
+ source=vote_action.actor_id,
186
+ )
187
+ else:
188
+ self._ballot.add_vote(vote_action.actor_id, ABSTAIN_VOTE)
189
+ state.push_event(
190
+ description=f'Invalid vote attempt by "{vote_action.actor_id}".',
191
+ event_name=EventName.ERROR,
192
+ public=False,
193
+ visible_to=self._expected_voters,
194
+ data=data,
195
+ )
196
+ return
197
+ else:
198
+ state.push_event(
199
+ description=f"Invalid vote attempt by {vote_action.actor_id}.",
200
+ event_name=EventName.ERROR,
201
+ public=False,
202
+ data=data,
203
+ )
204
+
205
+ def get_voting_prompt(self, state: GameState, player_id: PlayerID) -> str:
206
+ target_options = [
207
+ p_id
208
+ for p_id in self._potential_targets
209
+ if state.get_player_by_id(p_id) and state.get_player_by_id(p_id).alive
210
+ ]
211
+ return f'Player "{player_id}", please cast your vote. Options: {target_options} or Abstain ("{ABSTAIN_VOTE}").'
212
+
213
+ def get_current_tally_info(self, state: GameState) -> Dict[PlayerID, int]:
214
+ return self._ballot.get_tally()
215
+
216
+ def get_next_voters(self) -> List[PlayerID]:
217
+ # For simultaneous, all expected voters vote at once, and only once.
218
+ return [voter for voter in self._expected_voters if voter not in self._ballot.get_all_votes()]
219
+
220
+ def done(self) -> bool:
221
+ # The voting is considered "done" after one tick where voters were requested.
222
+ # The moderator will then call tally_votes.
223
+ return all(voter in self._ballot.get_all_votes() for voter in self._expected_voters)
224
+
225
+ def get_valid_targets(self) -> List[PlayerID]:
226
+ # Return a copy of targets that were valid (alive) at the start of voting.
227
+ return list(self._potential_targets)
228
+
229
+ def get_elected(self) -> PlayerID | None: # Return type matches tally_votes
230
+ if not self.done():
231
+ raise Exception("Voting is not done yet.")
232
+ if self._elected is None and not self._done_tallying:
233
+ self._elected = self._ballot.get_elected(self._potential_targets)
234
+ self._done_tallying = True
235
+ return self._elected
236
+
237
+
238
+ @register_protocol()
239
+ class SequentialVoting(VotingProtocol):
240
+ """
241
+ Players vote one by one in a sequence. Each player is shown the current
242
+ tally before casting their vote. All players in the initial list of
243
+ voters get a turn.
244
+ """
245
+
246
+ def __init__(self, assign_random_first_voter: bool = True, tie_break: TieBreak = TieBreak.RANDOM):
247
+ self._potential_targets: List[PlayerID] = []
248
+ self._voter_queue: List[PlayerID] = [] # Order of players to vote
249
+ self._expected_voters: List[PlayerID] = []
250
+ self._current_voter_index: int = 0 # Index for _voter_queue
251
+ self._current_game_state: Optional[GameState] = None # To store state from begin_voting
252
+ self._elected: Optional[PlayerID] = None
253
+ self._done_tallying = False
254
+ self._assign_random_first_voter = assign_random_first_voter
255
+ self._player_ids = None
256
+ self._ballot = Ballot(tie_selection=tie_break)
257
+
258
+ def reset(self) -> None:
259
+ self._ballot.reset()
260
+ self._potential_targets = []
261
+ self._expected_voters = []
262
+ self._voter_queue = []
263
+ self._current_voter_index = 0
264
+ self._current_game_state = None
265
+ self._elected = None
266
+ self._done_tallying = False
267
+
268
+ @property
269
+ def display_name(self) -> str:
270
+ return "Sequential Voting"
271
+
272
+ @property
273
+ def rule(self) -> str:
274
+ return (
275
+ "Players vote one by one. Player with the most votes after all have voted is exiled."
276
+ " Ties are broken randomly."
277
+ )
278
+
279
+ def begin_voting(self, state: GameState, alive_voters: Sequence[Player], potential_targets: Sequence[Player]):
280
+ if self._player_ids is None:
281
+ # initialize player_ids once.
282
+ self._player_ids = deque(state.all_player_ids)
283
+ if self._assign_random_first_voter:
284
+ self._player_ids.rotate(random.randrange(len(self._player_ids)))
285
+ alive_voter_ids = [p.id for p in alive_voters]
286
+ alive_voter_ids_set = set(alive_voter_ids)
287
+ self._ballot.reset()
288
+ self._expected_voters = [pid for pid in self._player_ids if pid in alive_voter_ids_set]
289
+ self._potential_targets = [p.id for p in potential_targets]
290
+ # The order of voting can be based on player ID, a random shuffle, or the order in alive_voters
291
+ # For simplicity, using the order from alive_voters.
292
+ self._voter_queue = list(self._expected_voters)
293
+ self._current_voter_index = 0
294
+ self._current_game_state = state # Store the game state reference
295
+
296
+ if self._expected_voters:
297
+ data = VoteOrderDataEntry(vote_order_of_player_ids=self._expected_voters)
298
+ state.push_event(
299
+ description=f"Voting starts from player {self._expected_voters[0]} "
300
+ f"with the following order: {self._expected_voters}",
301
+ event_name=EventName.VOTE_ORDER,
302
+ public=False,
303
+ visible_to=alive_voter_ids,
304
+ data=data,
305
+ )
306
+
307
+ def get_voting_prompt(self, state: GameState, player_id: PlayerID) -> str:
308
+ """
309
+ Generates a prompt for the given player_id, assuming it's their turn.
310
+ """
311
+ current_tally = self.get_current_tally_info(state)
312
+
313
+ # Sort for consistent display
314
+ tally_str_parts = []
315
+ for target_id, votes in sorted(current_tally.items(), key=lambda x: x[1], reverse=True):
316
+ tally_str_parts.append(f"{target_id}: {votes} vote(s)")
317
+
318
+ tally_str = "; ".join(tally_str_parts) if tally_str_parts else "No votes cast yet."
319
+
320
+ options_str_parts = []
321
+ for p_target in state.alive_players(): # Iterate through all alive players for options
322
+ if p_target.id in self._potential_targets:
323
+ options_str_parts.append(f"{p_target.id}")
324
+ options_str = ", ".join(options_str_parts)
325
+
326
+ return (
327
+ f"{player_id}, it is your turn to vote. "
328
+ f"Current tally: {tally_str}. "
329
+ f"Options: {options_str} or Abstain (vote for {ABSTAIN_VOTE})."
330
+ )
331
+
332
+ def collect_votes(self, player_actions: Dict[PlayerID, Action], state: GameState, expected_voters: List[PlayerID]):
333
+ if self.done():
334
+ return
335
+
336
+ # In sequential voting, expected_voters should contain exactly one player.
337
+ if not expected_voters:
338
+ # This case should ideally not be reached if `done()` is false.
339
+ # If it is, advancing the turn might be a safe way to prevent a stall.
340
+ self._current_voter_index += 1
341
+ return
342
+
343
+ expected_voter_id = expected_voters[0]
344
+ action = player_actions.get(expected_voter_id)
345
+
346
+ if action:
347
+ self.collect_vote(action, state)
348
+ else:
349
+ # This block handles timeout for the expected voter.
350
+ # The player did not submit an action. Treat as NoOp/Abstain.
351
+ self.collect_vote(NoOpAction(actor_id=expected_voter_id, day=state.day_count, phase=state.phase), state)
352
+
353
+ def collect_vote(self, vote_action: Action, state: GameState):
354
+ if not isinstance(vote_action, (VoteAction, NoOpAction)):
355
+ # Silently ignore if not a VoteAction or NoOpAction.
356
+ # Consider logging an "unexpected action type" error if more verbosity is needed.
357
+ return
358
+
359
+ if self.done():
360
+ state.push_event(
361
+ description=f"Action ({vote_action.kind}) received from {vote_action.actor_id}, "
362
+ f"but voting is already complete.",
363
+ event_name=EventName.ERROR,
364
+ public=False,
365
+ visible_to=[vote_action.actor_id],
366
+ )
367
+ return
368
+
369
+ expected_voter_id = self._voter_queue[self._current_voter_index]
370
+ if vote_action.actor_id != expected_voter_id:
371
+ state.push_event(
372
+ description=f"Action ({vote_action.kind}) received from {vote_action.actor_id}, "
373
+ f"but it is {expected_voter_id}'s turn.",
374
+ event_name=EventName.ERROR,
375
+ public=False, # Or public if strict turn enforcement is announced
376
+ visible_to=[vote_action.actor_id, expected_voter_id],
377
+ )
378
+ return
379
+
380
+ actor_player = next((p for p in state.players if p.id == vote_action.actor_id), None)
381
+ if actor_player and actor_player.alive:
382
+ description_for_event = ""
383
+ involved_players_list = [vote_action.actor_id] # Actor is always involved
384
+ data = None
385
+ if isinstance(vote_action, NoOpAction):
386
+ self._ballot.add_vote(vote_action.actor_id, ABSTAIN_VOTE) # Treat NoOp as abstain
387
+ description_for_event = f"{vote_action.actor_id} chose to NoOp (treated as Abstain)."
388
+
389
+ elif isinstance(vote_action, VoteAction): # This must be true if not NoOpAction
390
+ target_display: str
391
+ recorded_target_id = vote_action.target_id
392
+ if vote_action.target_id != ABSTAIN_VOTE and vote_action.target_id not in self._potential_targets:
393
+ # Invalid target chosen for VoteAction
394
+ state.push_event(
395
+ description=f"{vote_action.actor_id} attempted to vote for {vote_action.target_id} "
396
+ f"(invalid target). Vote recorded as Abstain.",
397
+ event_name=EventName.ERROR,
398
+ public=False,
399
+ visible_to=[vote_action.actor_id],
400
+ )
401
+ recorded_target_id = ABSTAIN_VOTE # Treat invalid target as abstain
402
+ target_display = f"Invalid Target ({vote_action.target_id}), recorded as Abstain"
403
+ elif vote_action.target_id == ABSTAIN_VOTE:
404
+ # Explicit Abstain via VoteAction
405
+ target_display = "Abstain"
406
+ # recorded_target_id is already ABSTAIN_VOTE
407
+ else:
408
+ # Valid target chosen for VoteAction
409
+ target_display = f"{vote_action.target_id}"
410
+ involved_players_list.append(vote_action.target_id) # Add valid target to involved
411
+
412
+ self._ballot.add_vote(vote_action.actor_id, recorded_target_id)
413
+ description_for_event = f"{vote_action.actor_id} has voted for {target_display}."
414
+
415
+ # Add data entry for the vote
416
+ data_entry_class = DayExileVoteDataEntry if state.phase == Phase.DAY else WerewolfNightVoteDataEntry
417
+ data = data_entry_class(
418
+ actor_id=vote_action.actor_id,
419
+ target_id=recorded_target_id,
420
+ reasoning=vote_action.reasoning,
421
+ perceived_threat_level=vote_action.perceived_threat_level,
422
+ action=vote_action,
423
+ )
424
+
425
+ state.push_event(
426
+ description=description_for_event,
427
+ event_name=EventName.VOTE_ACTION,
428
+ public=False,
429
+ visible_to=self._expected_voters,
430
+ data=data,
431
+ source=vote_action.actor_id,
432
+ )
433
+ self._current_voter_index += 1
434
+ else: # Player not found, not alive, or (redundantly) not their turn
435
+ state.push_event(
436
+ description=f"Invalid action ({vote_action.kind}) attempt by {vote_action.actor_id} (player not found,"
437
+ f" not alive, or not their turn). Action not counted.",
438
+ event_name=EventName.ERROR,
439
+ public=False,
440
+ visible_to=[vote_action.actor_id],
441
+ )
442
+ # If voter was expected but found to be not alive, advance turn to prevent stall
443
+ if vote_action.actor_id == expected_voter_id: # Implies actor_player was found but not actor_player.alive
444
+ self._current_voter_index += 1
445
+
446
+ def get_current_tally_info(self, state: GameState) -> Dict[str, int]:
447
+ # Returns counts of non-abstain votes for valid targets
448
+ return self._ballot.get_tally()
449
+
450
+ def get_next_voters(self) -> List[PlayerID]:
451
+ if not self.done():
452
+ # Ensure _current_voter_index is within bounds before accessing
453
+ if self._current_voter_index < len(self._voter_queue):
454
+ return [self._voter_queue[self._current_voter_index]]
455
+ return []
456
+
457
+ def done(self) -> bool:
458
+ if not self._voter_queue: # No voters were ever in the queue
459
+ return True
460
+ return self._current_voter_index >= len(self._voter_queue)
461
+
462
+ def get_valid_targets(self) -> List[PlayerID]:
463
+ return list(self._potential_targets)
464
+
465
+ def get_elected(self) -> Optional[PlayerID]:
466
+ if not self.done():
467
+ raise Exception("Voting is not done yet.")
468
+ if self._elected is None and not self._done_tallying:
469
+ self._elected = self._ballot.get_elected(self._potential_targets)
470
+ self._done_tallying = True
471
+ return self._elected