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,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)
|