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

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

Potentially problematic release.


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

Files changed (61) hide show
  1. kaggle_environments/__init__.py +2 -2
  2. kaggle_environments/envs/cabt/cabt.js +8 -8
  3. kaggle_environments/envs/cabt/cg/cg.dll +0 -0
  4. kaggle_environments/envs/cabt/cg/libcg.so +0 -0
  5. kaggle_environments/envs/open_spiel_env/games/repeated_poker/repeated_poker.js +673 -0
  6. kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker.js +52 -28
  7. kaggle_environments/envs/{open_spiel/open_spiel.py → open_spiel_env/open_spiel_env.py} +50 -1
  8. kaggle_environments/envs/{open_spiel/test_open_spiel.py → open_spiel_env/test_open_spiel_env.py} +84 -1
  9. kaggle_environments/envs/werewolf/GAME_RULE.md +75 -0
  10. kaggle_environments/envs/werewolf/__init__.py +0 -0
  11. kaggle_environments/envs/werewolf/game/__init__.py +0 -0
  12. kaggle_environments/envs/werewolf/game/actions.py +268 -0
  13. kaggle_environments/envs/werewolf/game/base.py +115 -0
  14. kaggle_environments/envs/werewolf/game/consts.py +156 -0
  15. kaggle_environments/envs/werewolf/game/engine.py +580 -0
  16. kaggle_environments/envs/werewolf/game/night_elimination_manager.py +101 -0
  17. kaggle_environments/envs/werewolf/game/protocols/__init__.py +4 -0
  18. kaggle_environments/envs/werewolf/game/protocols/base.py +242 -0
  19. kaggle_environments/envs/werewolf/game/protocols/bid.py +248 -0
  20. kaggle_environments/envs/werewolf/game/protocols/chat.py +467 -0
  21. kaggle_environments/envs/werewolf/game/protocols/factory.py +59 -0
  22. kaggle_environments/envs/werewolf/game/protocols/vote.py +471 -0
  23. kaggle_environments/envs/werewolf/game/records.py +334 -0
  24. kaggle_environments/envs/werewolf/game/roles.py +326 -0
  25. kaggle_environments/envs/werewolf/game/states.py +214 -0
  26. kaggle_environments/envs/werewolf/game/test_actions.py +45 -0
  27. kaggle_environments/envs/werewolf/test_werewolf.py +161 -0
  28. kaggle_environments/envs/werewolf/test_werewolf_deterministic.py +211 -0
  29. kaggle_environments/envs/werewolf/werewolf.js +4377 -0
  30. kaggle_environments/envs/werewolf/werewolf.json +286 -0
  31. kaggle_environments/envs/werewolf/werewolf.py +602 -0
  32. kaggle_environments/static/player.html +19 -1
  33. kaggle_environments-1.22.0.dist-info/METADATA +30 -0
  34. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.22.0.dist-info}/RECORD +56 -36
  35. kaggle_environments/envs/chess/chess.js +0 -4289
  36. kaggle_environments/envs/chess/chess.json +0 -60
  37. kaggle_environments/envs/chess/chess.py +0 -4241
  38. kaggle_environments/envs/chess/test_chess.py +0 -60
  39. kaggle_environments-1.20.1.dist-info/METADATA +0 -315
  40. /kaggle_environments/envs/{open_spiel → open_spiel_env}/__init__.py +0 -0
  41. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/__init__.py +0 -0
  42. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/chess.js +0 -0
  43. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/image_config.jsonl +0 -0
  44. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/openings.jsonl +0 -0
  45. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/__init__.py +0 -0
  46. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four.js +0 -0
  47. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four_proxy.py +0 -0
  48. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/__init__.py +0 -0
  49. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go.js +0 -0
  50. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go_proxy.py +0 -0
  51. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/__init__.py +0 -0
  52. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe.js +0 -0
  53. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe_proxy.py +0 -0
  54. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/__init__.py +0 -0
  55. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker_proxy.py +0 -0
  56. /kaggle_environments/envs/{open_spiel → open_spiel_env}/html_playthrough_generator.py +0 -0
  57. /kaggle_environments/envs/{open_spiel → open_spiel_env}/observation.py +0 -0
  58. /kaggle_environments/envs/{open_spiel → open_spiel_env}/proxy.py +0 -0
  59. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.22.0.dist-info}/WHEEL +0 -0
  60. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.22.0.dist-info}/entry_points.txt +0 -0
  61. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.22.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