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.
- kaggle_environments/__init__.py +2 -2
- kaggle_environments/envs/cabt/cabt.js +8 -8
- kaggle_environments/envs/cabt/cg/cg.dll +0 -0
- kaggle_environments/envs/cabt/cg/libcg.so +0 -0
- kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker.js +52 -28
- kaggle_environments/envs/{open_spiel/open_spiel.py → open_spiel_env/open_spiel_env.py} +37 -1
- kaggle_environments/envs/{open_spiel/test_open_spiel.py → open_spiel_env/test_open_spiel_env.py} +65 -1
- kaggle_environments/envs/werewolf/GAME_RULE.md +75 -0
- kaggle_environments/envs/werewolf/__init__.py +0 -0
- kaggle_environments/envs/werewolf/game/__init__.py +0 -0
- kaggle_environments/envs/werewolf/game/actions.py +268 -0
- kaggle_environments/envs/werewolf/game/base.py +115 -0
- kaggle_environments/envs/werewolf/game/consts.py +156 -0
- kaggle_environments/envs/werewolf/game/engine.py +580 -0
- kaggle_environments/envs/werewolf/game/night_elimination_manager.py +101 -0
- kaggle_environments/envs/werewolf/game/protocols/__init__.py +4 -0
- kaggle_environments/envs/werewolf/game/protocols/base.py +242 -0
- kaggle_environments/envs/werewolf/game/protocols/bid.py +248 -0
- kaggle_environments/envs/werewolf/game/protocols/chat.py +467 -0
- kaggle_environments/envs/werewolf/game/protocols/factory.py +59 -0
- kaggle_environments/envs/werewolf/game/protocols/vote.py +471 -0
- kaggle_environments/envs/werewolf/game/records.py +334 -0
- kaggle_environments/envs/werewolf/game/roles.py +326 -0
- kaggle_environments/envs/werewolf/game/states.py +214 -0
- kaggle_environments/envs/werewolf/game/test_actions.py +45 -0
- kaggle_environments/envs/werewolf/test_werewolf.py +161 -0
- kaggle_environments/envs/werewolf/test_werewolf_deterministic.py +211 -0
- kaggle_environments/envs/werewolf/werewolf.js +4377 -0
- kaggle_environments/envs/werewolf/werewolf.json +286 -0
- kaggle_environments/envs/werewolf/werewolf.py +602 -0
- kaggle_environments/static/player.html +19 -1
- kaggle_environments-1.21.0.dist-info/METADATA +30 -0
- {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/RECORD +55 -36
- kaggle_environments/envs/chess/chess.js +0 -4289
- kaggle_environments/envs/chess/chess.json +0 -60
- kaggle_environments/envs/chess/chess.py +0 -4241
- kaggle_environments/envs/chess/test_chess.py +0 -60
- kaggle_environments-1.20.1.dist-info/METADATA +0 -315
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/__init__.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/__init__.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/chess.js +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/image_config.jsonl +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/openings.jsonl +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/__init__.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four.js +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four_proxy.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/__init__.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go.js +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go_proxy.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/__init__.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe.js +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe_proxy.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/__init__.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker_proxy.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/html_playthrough_generator.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/observation.py +0 -0
- /kaggle_environments/envs/{open_spiel → open_spiel_env}/proxy.py +0 -0
- {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/WHEEL +0 -0
- {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/entry_points.txt +0 -0
- {kaggle_environments-1.20.1.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
|