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,334 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_serializer
|
|
9
|
+
|
|
10
|
+
from .base import BaseAction, BaseEvent, PlayerID
|
|
11
|
+
from .consts import DetailedPhase, EventName, ObsKeys, PerceivedThreatLevel, Phase, PhaseDivider, RoleConst, Team
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_utc_now():
|
|
15
|
+
return str(datetime.now(ZoneInfo("UTC")))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DataAccessLevel(IntEnum):
|
|
19
|
+
PUBLIC = 0
|
|
20
|
+
PERSONAL = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DataEntry(BaseModel, ABC):
|
|
24
|
+
"""Abstract base class for all data entry types."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActionDataMixin(BaseModel):
|
|
30
|
+
"""
|
|
31
|
+
A mixin for action-related DataEntry models.
|
|
32
|
+
Includes the actor performing the action and their private reasoning.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
actor_id: PlayerID
|
|
36
|
+
reasoning: Optional[str] = Field(
|
|
37
|
+
default=None, description="Private reasoning for moderator analysis.", access=DataAccessLevel.PERSONAL
|
|
38
|
+
)
|
|
39
|
+
perceived_threat_level: Optional[PerceivedThreatLevel] = Field(
|
|
40
|
+
default=PerceivedThreatLevel.SAFE, access=DataAccessLevel.PERSONAL
|
|
41
|
+
)
|
|
42
|
+
action: Optional[BaseAction] = Field(default=None, access=DataAccessLevel.PERSONAL)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class VisibleRawData(BaseModel):
|
|
46
|
+
data_type: str
|
|
47
|
+
json_str: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class PlayerEventView(BaseModel):
|
|
51
|
+
day: int
|
|
52
|
+
phase: Phase
|
|
53
|
+
detailed_phase: DetailedPhase
|
|
54
|
+
event_name: EventName
|
|
55
|
+
description: str
|
|
56
|
+
data: Optional[dict | DataEntry] = None
|
|
57
|
+
source: str
|
|
58
|
+
created_at: str
|
|
59
|
+
|
|
60
|
+
@model_serializer
|
|
61
|
+
def serialize(self) -> dict:
|
|
62
|
+
if isinstance(self.data, DataEntry):
|
|
63
|
+
data = self.data.model_dump()
|
|
64
|
+
else:
|
|
65
|
+
data = self.data
|
|
66
|
+
return dict(
|
|
67
|
+
day=self.day,
|
|
68
|
+
phase=self.phase,
|
|
69
|
+
detailed_phase=self.detailed_phase,
|
|
70
|
+
event_name=self.event_name,
|
|
71
|
+
description=self.description,
|
|
72
|
+
data=data,
|
|
73
|
+
source=self.source,
|
|
74
|
+
created_at=self.created_at,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Event(BaseEvent):
|
|
79
|
+
day: int # Day number, 0 for initial night
|
|
80
|
+
phase: Phase
|
|
81
|
+
detailed_phase: DetailedPhase
|
|
82
|
+
event_name: EventName
|
|
83
|
+
description: str
|
|
84
|
+
public: bool = False
|
|
85
|
+
visible_to: List[str] = Field(default_factory=list)
|
|
86
|
+
data: Optional[dict | DataEntry] = None
|
|
87
|
+
source: str
|
|
88
|
+
created_at: str = Field(default_factory=get_utc_now)
|
|
89
|
+
visible_in_ui: bool = True
|
|
90
|
+
"""Determine if visible to game viewer in UI. Has no effect to game engine flow."""
|
|
91
|
+
|
|
92
|
+
@field_serializer("data")
|
|
93
|
+
def serialize_data(self, data):
|
|
94
|
+
if data is None:
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(data, dict):
|
|
97
|
+
return data
|
|
98
|
+
if isinstance(data, BaseModel):
|
|
99
|
+
return data.model_dump()
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def serialize(self):
|
|
103
|
+
# TODO: this is purely constructed for compatibility with html renderer. Need to refactor werewolf.js to handle
|
|
104
|
+
# a direct model_dump of Event
|
|
105
|
+
data_dict = self.model_dump()
|
|
106
|
+
return VisibleRawData(data_type=self.data.__class__.__name__, json_str=json.dumps(data_dict)).model_dump()
|
|
107
|
+
|
|
108
|
+
def view_by_access(self, user_level: DataAccessLevel) -> PlayerEventView:
|
|
109
|
+
if isinstance(self.data, ActionDataMixin):
|
|
110
|
+
fields_to_include = set()
|
|
111
|
+
fields_to_exclude = set()
|
|
112
|
+
for name, info in self.data.__class__.model_fields.items():
|
|
113
|
+
if info.json_schema_extra:
|
|
114
|
+
if user_level >= info.json_schema_extra.get("access", DataAccessLevel.PUBLIC):
|
|
115
|
+
fields_to_include.add(name)
|
|
116
|
+
else:
|
|
117
|
+
fields_to_exclude.add(name)
|
|
118
|
+
else:
|
|
119
|
+
fields_to_include.add(name)
|
|
120
|
+
data = self.data.model_dump(include=fields_to_include, exclude=fields_to_exclude)
|
|
121
|
+
else:
|
|
122
|
+
data = self.data
|
|
123
|
+
out = PlayerEventView(
|
|
124
|
+
day=self.day,
|
|
125
|
+
phase=self.phase,
|
|
126
|
+
detailed_phase=self.detailed_phase,
|
|
127
|
+
event_name=self.event_name,
|
|
128
|
+
description=self.description,
|
|
129
|
+
data=data,
|
|
130
|
+
source=self.source,
|
|
131
|
+
created_at=self.created_at,
|
|
132
|
+
)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- Game State and Setup Data Entries ---
|
|
137
|
+
class GameStartDataEntry(DataEntry):
|
|
138
|
+
player_ids: List[PlayerID]
|
|
139
|
+
number_of_players: int
|
|
140
|
+
role_counts: Dict[RoleConst, int]
|
|
141
|
+
team_member_counts: Dict[Team, int]
|
|
142
|
+
day_discussion_protocol_name: str
|
|
143
|
+
day_discussion_display_name: str
|
|
144
|
+
day_discussion_protocol_rule: str
|
|
145
|
+
night_werewolf_discussion_protocol_name: str
|
|
146
|
+
night_werewolf_discussion_display_name: str
|
|
147
|
+
night_werewolf_discussion_protocol_rule: str
|
|
148
|
+
day_voting_protocol_name: str
|
|
149
|
+
day_voting_display_name: str
|
|
150
|
+
day_voting_protocol_rule: str
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class GameStartRoleDataEntry(DataEntry):
|
|
154
|
+
player_id: PlayerID
|
|
155
|
+
team: Team
|
|
156
|
+
role: RoleConst
|
|
157
|
+
rule_of_role: str
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SetNewPhaseDataEntry(DataEntry):
|
|
161
|
+
new_detailed_phase: DetailedPhase
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class PhaseDividerDataEntry(DataEntry):
|
|
165
|
+
divider_type: PhaseDivider
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --- Request for Action Data Entries ---
|
|
169
|
+
class RequestForActionDataEntry(DataEntry):
|
|
170
|
+
action_json_schema: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class RequestDoctorSaveDataEntry(RequestForActionDataEntry):
|
|
174
|
+
valid_candidates: List[PlayerID]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class RequestSeerRevealDataEntry(RequestForActionDataEntry):
|
|
178
|
+
valid_candidates: List[PlayerID]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class RequestWerewolfVotingDataEntry(RequestForActionDataEntry):
|
|
182
|
+
valid_targets: List[PlayerID]
|
|
183
|
+
alive_werewolve_player_ids: List[PlayerID]
|
|
184
|
+
voting_protocol_name: str
|
|
185
|
+
voting_protocol_rule: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class RequestVillagerToSpeakDataEntry(RequestForActionDataEntry):
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- Action and Result Data Entries ---
|
|
193
|
+
class SeerInspectResultDataEntry(DataEntry):
|
|
194
|
+
actor_id: PlayerID
|
|
195
|
+
target_id: PlayerID
|
|
196
|
+
role: Optional[RoleConst]
|
|
197
|
+
team: Optional[Team]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TargetedActionDataEntry(ActionDataMixin, DataEntry):
|
|
201
|
+
target_id: PlayerID
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SeerInspectActionDataEntry(TargetedActionDataEntry):
|
|
205
|
+
"""This records the Seer's choice of target to inspect."""
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class DoctorHealActionDataEntry(TargetedActionDataEntry):
|
|
209
|
+
"""This records the Doctor's choice of target to heal."""
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class WerewolfNightVoteDataEntry(TargetedActionDataEntry):
|
|
213
|
+
"""Records a werewolf's vote, including private reasoning."""
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class DayExileVoteDataEntry(TargetedActionDataEntry):
|
|
217
|
+
"""Records a player's vote to exile, including private reasoning."""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class DoctorSaveDataEntry(DataEntry):
|
|
221
|
+
"""This records that a player was successfully saved by a doctor."""
|
|
222
|
+
|
|
223
|
+
saved_player_id: PlayerID
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class VoteOrderDataEntry(DataEntry):
|
|
227
|
+
vote_order_of_player_ids: List[PlayerID]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class WerewolfNightEliminationElectedDataEntry(DataEntry):
|
|
231
|
+
"""This record the elected elimination target by werewolves."""
|
|
232
|
+
|
|
233
|
+
elected_target_player_id: PlayerID
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class WerewolfNightEliminationDataEntry(DataEntry):
|
|
237
|
+
"""This record the one eventually got eliminated by werewolves without doctor safe."""
|
|
238
|
+
|
|
239
|
+
eliminated_player_id: PlayerID
|
|
240
|
+
eliminated_player_role_name: Optional[RoleConst] = None
|
|
241
|
+
eliminated_player_team_name: Optional[Team] = None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class DayExileElectedDataEntry(DataEntry):
|
|
245
|
+
elected_player_id: PlayerID
|
|
246
|
+
elected_player_role_name: Optional[RoleConst] = None
|
|
247
|
+
elected_player_team_name: Optional[Team] = None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class DiscussionOrderDataEntry(DataEntry):
|
|
251
|
+
chat_order_of_player_ids: List[PlayerID]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ChatDataEntry(ActionDataMixin, DataEntry):
|
|
255
|
+
"""Records a chat message from a player, including private reasoning."""
|
|
256
|
+
|
|
257
|
+
# actor_id and reasoning are inherited from ActionDataMixin
|
|
258
|
+
message: str
|
|
259
|
+
mentioned_player_ids: List[PlayerID] = Field(default_factory=list)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class BidDataEntry(ActionDataMixin, DataEntry):
|
|
263
|
+
bid_amount: int
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class BidResultDataEntry(DataEntry):
|
|
267
|
+
winner_player_ids: List[PlayerID]
|
|
268
|
+
bid_overview: Dict[PlayerID, int]
|
|
269
|
+
mentioned_players_in_previous_turn: List[PlayerID] = []
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --- Game End and Observation Models (Unchanged) ---
|
|
273
|
+
class GameEndResultsDataEntry(DataEntry):
|
|
274
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
275
|
+
|
|
276
|
+
winner_team: Team
|
|
277
|
+
winner_ids: List[PlayerID]
|
|
278
|
+
loser_ids: List[PlayerID]
|
|
279
|
+
scores: Dict[str, int | float]
|
|
280
|
+
reason: str
|
|
281
|
+
last_day: int
|
|
282
|
+
last_phase: Phase
|
|
283
|
+
survivors_until_last_round_and_role: Dict[PlayerID, RoleConst]
|
|
284
|
+
all_players_and_role: Dict[PlayerID, RoleConst]
|
|
285
|
+
elimination_info: List[Dict]
|
|
286
|
+
"""list each player's elimination status, see GameState.get_elimination_info"""
|
|
287
|
+
|
|
288
|
+
all_players: List[Dict]
|
|
289
|
+
"""provide the info dump for each player"""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class WerewolfObservationModel(BaseModel):
|
|
293
|
+
player_id: PlayerID
|
|
294
|
+
role: RoleConst
|
|
295
|
+
team: Team
|
|
296
|
+
is_alive: bool
|
|
297
|
+
day: int
|
|
298
|
+
detailed_phase: DetailedPhase
|
|
299
|
+
all_player_ids: List[PlayerID]
|
|
300
|
+
player_thumbnails: Dict[PlayerID, str] = {}
|
|
301
|
+
alive_players: List[PlayerID]
|
|
302
|
+
revealed_players: Dict[PlayerID, RoleConst | Team | None] = {}
|
|
303
|
+
new_visible_announcements: List[str]
|
|
304
|
+
new_player_event_views: List[PlayerEventView]
|
|
305
|
+
game_state_phase: Phase
|
|
306
|
+
|
|
307
|
+
def get_human_readable(self) -> str:
|
|
308
|
+
# This is a placeholder implementation. A real implementation would format this nicely.
|
|
309
|
+
return json.dumps(self.model_dump(), indent=2)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def set_raw_observation(kaggle_player_state, raw_obs: WerewolfObservationModel):
|
|
313
|
+
"""Persist raw observations for players in kaggle's player state
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
kaggle_player_state: Kaggle's interpreter state is a list of player state. This arg is one player state item.
|
|
317
|
+
raw_obs: the raw observation for a player extracted from game engine.
|
|
318
|
+
|
|
319
|
+
Note: using raw_obs.model_dump_json() will greatly increase rendering speed (due to kaggle environment's use
|
|
320
|
+
of deepcopy for serialization) at the expense of harder to parse JSON rendering, since we are getting a json
|
|
321
|
+
string instead of human-readable dump. We choose raw_obs.model_dump() for clarity.
|
|
322
|
+
"""
|
|
323
|
+
kaggle_player_state.observation[ObsKeys.RAW_OBSERVATION] = raw_obs.model_dump()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def get_raw_observation(kaggle_observation) -> WerewolfObservationModel:
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
kaggle_observation:
|
|
331
|
+
|
|
332
|
+
Returns: a dict of WerewolfObservationModel dump
|
|
333
|
+
"""
|
|
334
|
+
return WerewolfObservationModel(**kaggle_observation[ObsKeys.RAW_OBSERVATION])
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from collections import Counter, defaultdict, deque
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Deque, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
from .actions import HealAction, InspectAction
|
|
10
|
+
from .base import BaseModerator, BasePlayer, BaseRole, EventHandler, PlayerID, on_event
|
|
11
|
+
from .consts import EventName, Phase, RevealLevel, RoleConst, Team
|
|
12
|
+
from .records import (
|
|
13
|
+
Event,
|
|
14
|
+
PlayerEventView,
|
|
15
|
+
RequestDoctorSaveDataEntry,
|
|
16
|
+
RequestSeerRevealDataEntry,
|
|
17
|
+
SeerInspectResultDataEntry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Role(BaseRole):
|
|
24
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
25
|
+
|
|
26
|
+
name: RoleConst = Field(..., frozen=True)
|
|
27
|
+
team: Team
|
|
28
|
+
night_priority: int = 100 # lower number acts earlier
|
|
29
|
+
descriptions: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Werewolf(Role):
|
|
33
|
+
name: RoleConst = RoleConst.WEREWOLF
|
|
34
|
+
team: Team = Team.WEREWOLVES
|
|
35
|
+
night_priority: int = 2
|
|
36
|
+
descriptions: str = "Each night, collaborates with fellow werewolves to vote on eliminating one player."
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Villager(Role):
|
|
40
|
+
name: RoleConst = RoleConst.VILLAGER
|
|
41
|
+
team: Team = Team.VILLAGERS
|
|
42
|
+
descriptions: str = "No special abilities. Participates in the daily vote to eliminate a suspected werewolf."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DoctorDescription:
|
|
46
|
+
ALLOW_SELF_SAVE = "Each night, may protect one player from a werewolf attack. Doctor is allowed to save themselves during night time."
|
|
47
|
+
NO_SELF_SAVE = "Each night, may protect one player from a werewolf attack. Doctor is NOT allowed to save themselves during night time."
|
|
48
|
+
NO_CONSECUTIVE_SAVE = " Doctor is NOT allowed to save the same player on consecutive nights."
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DoctorStateKey:
|
|
52
|
+
LAST_SAVED_DAY = "last_saved_day"
|
|
53
|
+
LAST_SAVED_PLAYER_ID = "last_saved_player_id"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Doctor(Role):
|
|
57
|
+
name: RoleConst = RoleConst.DOCTOR
|
|
58
|
+
team: Team = Team.VILLAGERS
|
|
59
|
+
allow_self_save: bool = False
|
|
60
|
+
allow_consecutive_saves: bool = True
|
|
61
|
+
descriptions: str = ""
|
|
62
|
+
|
|
63
|
+
@model_validator(mode="after")
|
|
64
|
+
def set_descriptions_default(self) -> "Doctor":
|
|
65
|
+
if self.descriptions == "":
|
|
66
|
+
if self.allow_self_save:
|
|
67
|
+
self.descriptions = DoctorDescription.ALLOW_SELF_SAVE
|
|
68
|
+
else:
|
|
69
|
+
self.descriptions = DoctorDescription.NO_SELF_SAVE
|
|
70
|
+
if not self.allow_consecutive_saves:
|
|
71
|
+
self.descriptions += DoctorDescription.NO_CONSECUTIVE_SAVE
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
@on_event(EventName.NIGHT_START)
|
|
75
|
+
def on_night_starts(self, me: BasePlayer, moderator: BaseModerator, event: Event):
|
|
76
|
+
if me.alive:
|
|
77
|
+
current_day = moderator.state.day_count
|
|
78
|
+
last_saved_day = me.get_role_state(DoctorStateKey.LAST_SAVED_DAY, default=-1)
|
|
79
|
+
last_saved_player_id = me.get_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID)
|
|
80
|
+
|
|
81
|
+
# Reset consecutive save memory if a night was skipped
|
|
82
|
+
if not self.allow_consecutive_saves and last_saved_day != -1 and current_day > last_saved_day + 1:
|
|
83
|
+
me.set_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID, None)
|
|
84
|
+
last_saved_player_id = None
|
|
85
|
+
|
|
86
|
+
valid_candidates = [p.id for p in moderator.state.alive_players()]
|
|
87
|
+
|
|
88
|
+
if not self.allow_self_save:
|
|
89
|
+
valid_candidates = [p_id for p_id in valid_candidates if p_id != me.id]
|
|
90
|
+
|
|
91
|
+
prompt = "Wake up Doctor. Who would you like to save? "
|
|
92
|
+
if not self.allow_consecutive_saves and last_saved_player_id:
|
|
93
|
+
valid_candidates = [p_id for p_id in valid_candidates if p_id != last_saved_player_id]
|
|
94
|
+
prompt += f'You cannot save the same player on consecutive nights. Player "{last_saved_player_id}" is not a valid target this night. '
|
|
95
|
+
|
|
96
|
+
data_entry = RequestDoctorSaveDataEntry(
|
|
97
|
+
valid_candidates=valid_candidates, action_json_schema=json.dumps(HealAction.schema_for_player())
|
|
98
|
+
)
|
|
99
|
+
prompt += f"The options are {data_entry.valid_candidates}."
|
|
100
|
+
|
|
101
|
+
moderator.request_action(
|
|
102
|
+
action_cls=HealAction,
|
|
103
|
+
player_id=me.id,
|
|
104
|
+
prompt=prompt,
|
|
105
|
+
data=data_entry,
|
|
106
|
+
event_name=EventName.HEAL_REQUEST,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
@on_event(EventName.HEAL_ACTION)
|
|
110
|
+
def on_heal_action(self, me: BasePlayer, moderator: BaseModerator, event: Event):
|
|
111
|
+
if not me.alive or event.data.actor_id != me.id:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
action = event.data.action
|
|
115
|
+
if isinstance(action, HealAction):
|
|
116
|
+
if not self.allow_self_save and action.target_id == me.id:
|
|
117
|
+
moderator.state.push_event(
|
|
118
|
+
description=f'Player "{me.id}", doctor is not allowed to self save. '
|
|
119
|
+
f"Your target is {action.target_id}, which is your own id.",
|
|
120
|
+
event_name=EventName.ERROR,
|
|
121
|
+
public=False,
|
|
122
|
+
visible_to=[me.id],
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if not self.allow_consecutive_saves and action.target_id == me.get_role_state(
|
|
127
|
+
DoctorStateKey.LAST_SAVED_PLAYER_ID
|
|
128
|
+
):
|
|
129
|
+
moderator.state.push_event(
|
|
130
|
+
description=f'Player "{me.id}", you cannot save the same player on consecutive nights. '
|
|
131
|
+
f'Your target "{action.target_id}" was also saved last night.',
|
|
132
|
+
event_name=EventName.ERROR,
|
|
133
|
+
public=False,
|
|
134
|
+
visible_to=[me.id],
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
moderator.record_night_save(me.id, action.target_id)
|
|
139
|
+
me.set_role_state(DoctorStateKey.LAST_SAVED_PLAYER_ID, action.target_id)
|
|
140
|
+
me.set_role_state(DoctorStateKey.LAST_SAVED_DAY, moderator.state.day_count)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SeerDescription:
|
|
144
|
+
REVEAL_ROLE = "Each night, may inspect one player to learn their true role."
|
|
145
|
+
REVEAL_TEAM = "Each night, may inspect one player's team but not their role."
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Seer(Role):
|
|
149
|
+
name: RoleConst = RoleConst.SEER
|
|
150
|
+
team: Team = Team.VILLAGERS
|
|
151
|
+
descriptions: str = ""
|
|
152
|
+
reveal_level: RevealLevel = RevealLevel.ROLE
|
|
153
|
+
|
|
154
|
+
@field_validator("reveal_level")
|
|
155
|
+
@classmethod
|
|
156
|
+
def validate_reveal_level(cls, v):
|
|
157
|
+
if v == RevealLevel.NO_REVEAL:
|
|
158
|
+
raise ValueError(f"Setting reveal_level of Seer as {v}. Seer will become useless.")
|
|
159
|
+
return v
|
|
160
|
+
|
|
161
|
+
@model_validator(mode="after")
|
|
162
|
+
def set_descriptions_default(self) -> "Seer":
|
|
163
|
+
if self.descriptions == "":
|
|
164
|
+
if self.reveal_level == RevealLevel.ROLE:
|
|
165
|
+
self.descriptions = SeerDescription.REVEAL_ROLE
|
|
166
|
+
elif self.reveal_level == RevealLevel.TEAM:
|
|
167
|
+
self.descriptions = SeerDescription.REVEAL_TEAM
|
|
168
|
+
else:
|
|
169
|
+
raise ValueError(f"reveal_level {self.reveal_level} not supported.")
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
@on_event(EventName.NIGHT_START)
|
|
173
|
+
def on_night_starts(self, me: BasePlayer, moderator: BaseModerator, event: Event):
|
|
174
|
+
if me.alive:
|
|
175
|
+
data_entry = RequestSeerRevealDataEntry(
|
|
176
|
+
valid_candidates=[p.id for p in moderator.state.alive_players() if p != me],
|
|
177
|
+
action_json_schema=json.dumps(InspectAction.schema_for_player()),
|
|
178
|
+
)
|
|
179
|
+
moderator.request_action(
|
|
180
|
+
action_cls=InspectAction,
|
|
181
|
+
player_id=me.id,
|
|
182
|
+
prompt=f"Wake up Seer. Who would you like to see their true {self.reveal_level}? "
|
|
183
|
+
f"The options are {data_entry.valid_candidates}.",
|
|
184
|
+
data=data_entry,
|
|
185
|
+
event_name=EventName.INSPECT_REQUEST,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
@on_event(EventName.INSPECT_ACTION)
|
|
189
|
+
def on_inspect_action(self, me: BasePlayer, moderator: BaseModerator, event: Event):
|
|
190
|
+
action = event.data.action
|
|
191
|
+
if not me.alive or action.actor_id != me.id:
|
|
192
|
+
return
|
|
193
|
+
actor_id = me.id
|
|
194
|
+
target_player = moderator.state.get_player_by_id(action.target_id)
|
|
195
|
+
if target_player: # Ensure target exists
|
|
196
|
+
role = None
|
|
197
|
+
team = None
|
|
198
|
+
reveal_text = ""
|
|
199
|
+
if self.reveal_level == RevealLevel.ROLE:
|
|
200
|
+
role = target_player.role.name
|
|
201
|
+
team = target_player.role.team
|
|
202
|
+
reveal_text = f'Their role is a "{target_player.role.name}" in team "{target_player.role.team.value}".'
|
|
203
|
+
elif self.reveal_level == RevealLevel.TEAM:
|
|
204
|
+
team = target_player.role.team
|
|
205
|
+
reveal_text = f"Their team is {team}."
|
|
206
|
+
|
|
207
|
+
data = SeerInspectResultDataEntry(actor_id=actor_id, target_id=action.target_id, role=role, team=team)
|
|
208
|
+
moderator.state.push_event(
|
|
209
|
+
description=f'Player "{actor_id}", you inspected {target_player.id}. ' + reveal_text,
|
|
210
|
+
event_name=EventName.INSPECT_RESULT,
|
|
211
|
+
public=False,
|
|
212
|
+
visible_to=[actor_id],
|
|
213
|
+
data=data,
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
moderator.state.push_event(
|
|
217
|
+
description=f'Player "{actor_id}", you inspected player "{action.target_id}",'
|
|
218
|
+
f" but this player could not be found.",
|
|
219
|
+
event_name=EventName.ERROR,
|
|
220
|
+
public=False,
|
|
221
|
+
visible_to=[actor_id],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class LLM(BaseModel):
|
|
226
|
+
model_name: str
|
|
227
|
+
properties: Dict = {}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class Agent(BaseModel):
|
|
231
|
+
id: PlayerID
|
|
232
|
+
"""The unique name of the player."""
|
|
233
|
+
|
|
234
|
+
agent_id: str
|
|
235
|
+
"""Id of the agent. Might not be unique (many players might be using the same underlying agent)."""
|
|
236
|
+
|
|
237
|
+
display_name: str = ""
|
|
238
|
+
"""Agent name shown in the UI and only visible to spectator but not the players. e.g. Pete (base_harness-gemini-2.5-pro)
|
|
239
|
+
base_harness-gemini-2.5-pro is the display_name while Pete is the id. It maybe different from agent_id,
|
|
240
|
+
e.g. base_harness_v2-gemini-2.5-pro-0506, to reduce the cognitive load of the spectators.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
role: RoleConst
|
|
244
|
+
role_params: Dict = Field(default_factory=dict)
|
|
245
|
+
"""Parameters to the Role constructor"""
|
|
246
|
+
|
|
247
|
+
thumbnail: Optional[str] = ""
|
|
248
|
+
agent_harness_name: str = "basic_llm"
|
|
249
|
+
llms: List[LLM] = []
|
|
250
|
+
|
|
251
|
+
def get_agent_name(self):
|
|
252
|
+
return f"{self.agent_harness_name}({', '.join([llm.model_name for llm in self.llms])})"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class Player(BasePlayer):
|
|
256
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
257
|
+
|
|
258
|
+
id: PlayerID
|
|
259
|
+
"""The unique name of the player."""
|
|
260
|
+
|
|
261
|
+
agent: Agent
|
|
262
|
+
role: BaseRole
|
|
263
|
+
alive: bool = True
|
|
264
|
+
eliminated_during_day: int = -1
|
|
265
|
+
"""game starts at night 0, then day 1, night 1, day 2, ..."""
|
|
266
|
+
|
|
267
|
+
eliminated_during_phase: Optional[Phase] = None
|
|
268
|
+
|
|
269
|
+
_message_queue: Deque[PlayerEventView] = PrivateAttr(default_factory=deque)
|
|
270
|
+
_role_state: Dict = PrivateAttr(default_factory=dict)
|
|
271
|
+
|
|
272
|
+
def set_role_state(self, key, value):
|
|
273
|
+
self._role_state[key] = value
|
|
274
|
+
|
|
275
|
+
def get_role_state(self, key, default=None):
|
|
276
|
+
return self._role_state.get(key, default)
|
|
277
|
+
|
|
278
|
+
def get_event_handlers(self, moderator: BaseModerator) -> Dict[EventName, List[EventHandler]]:
|
|
279
|
+
handlers = defaultdict(list)
|
|
280
|
+
for event_type, handler in self.role.get_event_handlers().items():
|
|
281
|
+
event_handler = partial(handler, self, moderator)
|
|
282
|
+
handlers[event_type].append(event_handler)
|
|
283
|
+
return handlers
|
|
284
|
+
|
|
285
|
+
def update(self, entry: PlayerEventView):
|
|
286
|
+
self._message_queue.append(entry)
|
|
287
|
+
|
|
288
|
+
def consume_messages(self) -> List[PlayerEventView]:
|
|
289
|
+
messages = list(self._message_queue)
|
|
290
|
+
self._message_queue.clear()
|
|
291
|
+
return messages
|
|
292
|
+
|
|
293
|
+
def eliminate(self, day: int, phase: Phase):
|
|
294
|
+
self.alive = False
|
|
295
|
+
self.eliminated_during_day = day
|
|
296
|
+
self.eliminated_during_phase = phase.value
|
|
297
|
+
|
|
298
|
+
def report_elimination(self):
|
|
299
|
+
return {
|
|
300
|
+
"player_id": self.id,
|
|
301
|
+
"eliminated_during_day": self.eliminated_during_day,
|
|
302
|
+
"eliminated_during_phase": self.eliminated_during_phase,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
ROLE_CLASS_MAP = {
|
|
307
|
+
RoleConst.WEREWOLF.value: Werewolf,
|
|
308
|
+
RoleConst.DOCTOR.value: Doctor,
|
|
309
|
+
RoleConst.SEER.value: Seer,
|
|
310
|
+
RoleConst.VILLAGER.value: Villager,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def create_players_from_agents_config(agents_config: List[Dict]) -> List[Player]:
|
|
315
|
+
# check all agents have unique ids
|
|
316
|
+
agent_ids = [agent_config["id"] for agent_config in agents_config]
|
|
317
|
+
if len(agent_ids) != len(set(agent_ids)):
|
|
318
|
+
counts = Counter(agent_ids)
|
|
319
|
+
duplicates = [item for item, count in counts.items() if count > 1 and item is not None]
|
|
320
|
+
if duplicates:
|
|
321
|
+
raise ValueError(f"Duplicate agent ids found: {', '.join(duplicates)}")
|
|
322
|
+
agents = [Agent(**agent_config) for agent_config in agents_config]
|
|
323
|
+
players = [
|
|
324
|
+
Player(id=agent.id, agent=agent, role=ROLE_CLASS_MAP[agent.role](**agent.role_params)) for agent in agents
|
|
325
|
+
]
|
|
326
|
+
return players
|