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.

Files changed (60) hide show
  1. kaggle_environments/__init__.py +2 -2
  2. kaggle_environments/envs/cabt/cabt.js +8 -8
  3. kaggle_environments/envs/cabt/cg/cg.dll +0 -0
  4. kaggle_environments/envs/cabt/cg/libcg.so +0 -0
  5. kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker.js +52 -28
  6. kaggle_environments/envs/{open_spiel/open_spiel.py → open_spiel_env/open_spiel_env.py} +37 -1
  7. kaggle_environments/envs/{open_spiel/test_open_spiel.py → open_spiel_env/test_open_spiel_env.py} +65 -1
  8. kaggle_environments/envs/werewolf/GAME_RULE.md +75 -0
  9. kaggle_environments/envs/werewolf/__init__.py +0 -0
  10. kaggle_environments/envs/werewolf/game/__init__.py +0 -0
  11. kaggle_environments/envs/werewolf/game/actions.py +268 -0
  12. kaggle_environments/envs/werewolf/game/base.py +115 -0
  13. kaggle_environments/envs/werewolf/game/consts.py +156 -0
  14. kaggle_environments/envs/werewolf/game/engine.py +580 -0
  15. kaggle_environments/envs/werewolf/game/night_elimination_manager.py +101 -0
  16. kaggle_environments/envs/werewolf/game/protocols/__init__.py +4 -0
  17. kaggle_environments/envs/werewolf/game/protocols/base.py +242 -0
  18. kaggle_environments/envs/werewolf/game/protocols/bid.py +248 -0
  19. kaggle_environments/envs/werewolf/game/protocols/chat.py +467 -0
  20. kaggle_environments/envs/werewolf/game/protocols/factory.py +59 -0
  21. kaggle_environments/envs/werewolf/game/protocols/vote.py +471 -0
  22. kaggle_environments/envs/werewolf/game/records.py +334 -0
  23. kaggle_environments/envs/werewolf/game/roles.py +326 -0
  24. kaggle_environments/envs/werewolf/game/states.py +214 -0
  25. kaggle_environments/envs/werewolf/game/test_actions.py +45 -0
  26. kaggle_environments/envs/werewolf/test_werewolf.py +161 -0
  27. kaggle_environments/envs/werewolf/test_werewolf_deterministic.py +211 -0
  28. kaggle_environments/envs/werewolf/werewolf.js +4377 -0
  29. kaggle_environments/envs/werewolf/werewolf.json +286 -0
  30. kaggle_environments/envs/werewolf/werewolf.py +602 -0
  31. kaggle_environments/static/player.html +19 -1
  32. kaggle_environments-1.21.0.dist-info/METADATA +30 -0
  33. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/RECORD +55 -36
  34. kaggle_environments/envs/chess/chess.js +0 -4289
  35. kaggle_environments/envs/chess/chess.json +0 -60
  36. kaggle_environments/envs/chess/chess.py +0 -4241
  37. kaggle_environments/envs/chess/test_chess.py +0 -60
  38. kaggle_environments-1.20.1.dist-info/METADATA +0 -315
  39. /kaggle_environments/envs/{open_spiel → open_spiel_env}/__init__.py +0 -0
  40. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/__init__.py +0 -0
  41. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/chess.js +0 -0
  42. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/image_config.jsonl +0 -0
  43. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/chess/openings.jsonl +0 -0
  44. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/__init__.py +0 -0
  45. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four.js +0 -0
  46. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/connect_four/connect_four_proxy.py +0 -0
  47. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/__init__.py +0 -0
  48. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go.js +0 -0
  49. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/go/go_proxy.py +0 -0
  50. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/__init__.py +0 -0
  51. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe.js +0 -0
  52. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/tic_tac_toe/tic_tac_toe_proxy.py +0 -0
  53. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/__init__.py +0 -0
  54. /kaggle_environments/envs/{open_spiel → open_spiel_env}/games/universal_poker/universal_poker_proxy.py +0 -0
  55. /kaggle_environments/envs/{open_spiel → open_spiel_env}/html_playthrough_generator.py +0 -0
  56. /kaggle_environments/envs/{open_spiel → open_spiel_env}/observation.py +0 -0
  57. /kaggle_environments/envs/{open_spiel → open_spiel_env}/proxy.py +0 -0
  58. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/WHEEL +0 -0
  59. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/entry_points.txt +0 -0
  60. {kaggle_environments-1.20.1.dist-info → kaggle_environments-1.21.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,242 @@
1
+ import json
2
+ import re
3
+ from abc import ABC, abstractmethod
4
+ from typing import Dict, List, Optional, Sequence, Tuple
5
+
6
+ from kaggle_environments.envs.werewolf.game.actions import Action, BidAction, ChatAction
7
+ from kaggle_environments.envs.werewolf.game.base import PlayerID
8
+ from kaggle_environments.envs.werewolf.game.consts import EventName
9
+ from kaggle_environments.envs.werewolf.game.records import ChatDataEntry, RequestVillagerToSpeakDataEntry
10
+ from kaggle_environments.envs.werewolf.game.roles import Player
11
+ from kaggle_environments.envs.werewolf.game.states import GameState
12
+
13
+
14
+ def _extract_player_ids_from_string(text: str, all_player_ids: List[PlayerID]) -> List[PlayerID]:
15
+ """Extracts player IDs mentioned in a string."""
16
+ if not all_player_ids:
17
+ return []
18
+ # Create a regex pattern to find any of the player IDs as whole words
19
+ # Using a set for faster lookups and to handle duplicates from the regex
20
+ pattern = r"\b(" + "|".join(re.escape(pid) for pid in all_player_ids) + r")\b"
21
+ # Use a set to automatically handle duplicates found by the regex
22
+ found_ids = set(re.findall(pattern, text))
23
+ return sorted(list(found_ids)) # sorted for deterministic order
24
+
25
+
26
+ def _find_mentioned_players(text: str, all_player_ids: List[PlayerID]) -> List[PlayerID]:
27
+ """
28
+ Finds player IDs mentioned in a string of text, ordered by their first appearance.
29
+ Player IDs are treated as whole words.
30
+ Example: "I think gpt-4 is suspicious, what do you think John?" -> ["gpt-4", "John"]
31
+ """
32
+ if not text or not all_player_ids:
33
+ return []
34
+
35
+ # Sort by length descending to handle substrings correctly.
36
+ sorted_player_ids = sorted(all_player_ids, key=len, reverse=True)
37
+ pattern = r"\b(" + "|".join(re.escape(pid) for pid in sorted_player_ids) + r")\b"
38
+
39
+ matches = re.finditer(pattern, text)
40
+
41
+ # Deduplicate while preserving order of first appearance
42
+ ordered_mentioned_ids = []
43
+ seen = set()
44
+ for match in matches:
45
+ player_id = match.group(1)
46
+ if player_id not in seen:
47
+ ordered_mentioned_ids.append(player_id)
48
+ seen.add(player_id)
49
+
50
+ return ordered_mentioned_ids
51
+
52
+
53
+ class GameProtocol(ABC):
54
+ @property
55
+ def display_name(self) -> str:
56
+ return self.__class__.__name__
57
+
58
+ @property
59
+ @abstractmethod
60
+ def rule(self) -> str:
61
+ """Human-readable format of rule."""
62
+
63
+
64
+ class VotingProtocol(GameProtocol):
65
+ """Collects, validates, and tallies votes."""
66
+
67
+ @abstractmethod
68
+ def begin_voting(self, state: GameState, alive_voters: Sequence[Player], potential_targets: Sequence[Player]):
69
+ """Initialize for a new voting round."""
70
+
71
+ @abstractmethod
72
+ def get_voting_prompt(self, state: GameState, player_id: PlayerID) -> str:
73
+ """
74
+ Returns a string prompt for the specified player, potentially including current tally.
75
+ """
76
+
77
+ @abstractmethod
78
+ def collect_vote(self, vote_action: Action, state: GameState): # Changed to Action, will check type
79
+ """Collect an individual vote."""
80
+
81
+ @abstractmethod
82
+ def collect_votes(self, player_actions: Dict[str, Action], state: GameState, expected_voters: List[PlayerID]):
83
+ """Collect a batch of votes."""
84
+
85
+ @abstractmethod
86
+ def get_current_tally_info(self, state: GameState) -> Dict[PlayerID, PlayerID]:
87
+ """
88
+ Return the current tally by a map, where key is player, value is target.
89
+ """
90
+
91
+ @abstractmethod
92
+ def get_next_voters(self) -> List[PlayerID]:
93
+ """get the next batch of voters"""
94
+
95
+ @abstractmethod
96
+ def done(self):
97
+ """Check if voting is done."""
98
+
99
+ @abstractmethod
100
+ def get_valid_targets(self) -> List[PlayerID]:
101
+ """get a list of targets"""
102
+
103
+ @abstractmethod
104
+ def get_elected(self) -> Optional[PlayerID]:
105
+ """get the final elected individual, or None if no one was elected."""
106
+
107
+ @abstractmethod
108
+ def reset(self) -> None:
109
+ """Resets the protocol to its initial state."""
110
+ pass
111
+
112
+
113
+ class BiddingProtocol(GameProtocol):
114
+ """Drives one auction round and returns the winner(s)."""
115
+
116
+ @property
117
+ @abstractmethod
118
+ def bids(self) -> Dict[PlayerID, int]:
119
+ """return a snapshot of the current bids"""
120
+
121
+ @staticmethod
122
+ def get_last_mentioned(state: GameState) -> Tuple[List[PlayerID], str]:
123
+ """get the players that were mentioned in last player message."""
124
+ last_chat_message = ""
125
+ sorted_days = sorted(state.history.keys(), reverse=True)
126
+ for day in sorted_days:
127
+ for entry in reversed(state.history[day]):
128
+ if entry.event_name == EventName.DISCUSSION and isinstance(entry.data, ChatDataEntry):
129
+ last_chat_message = entry.data.message
130
+ break
131
+ if last_chat_message:
132
+ break
133
+ players = _find_mentioned_players(last_chat_message, state.all_player_ids)
134
+ return players, last_chat_message
135
+
136
+ @abstractmethod
137
+ def begin(self, state: GameState) -> None: ...
138
+
139
+ @abstractmethod
140
+ def accept(self, bid: BidAction, state: GameState) -> None: ...
141
+
142
+ @abstractmethod
143
+ def process_incoming_bids(self, actions: List[Action], state: GameState) -> None:
144
+ """Processes a batch of actions, handling BidActions by calling self.accept()."""
145
+
146
+ @abstractmethod
147
+ def is_finished(self, state: GameState) -> bool: ...
148
+
149
+ @abstractmethod
150
+ def outcome(self, state: GameState) -> list[PlayerID]:
151
+ """
152
+ Return list of player-ids, ordered by bid strength.
153
+ Could be 1 winner (sealed-bid) or a full ranking (Dutch auction).
154
+ """
155
+
156
+ @abstractmethod
157
+ def reset(self) -> None:
158
+ """Resets the protocol to its initial state."""
159
+
160
+
161
+ class DiscussionProtocol(GameProtocol):
162
+ """Drives the order/shape of daytime conversation."""
163
+
164
+ @abstractmethod
165
+ def begin(self, state: GameState) -> None:
166
+ """Optional hook – initialise timers, round counters…"""
167
+
168
+ @abstractmethod
169
+ def speakers_for_tick(self, state: GameState) -> Sequence[PlayerID]:
170
+ """
171
+ Return the IDs that are *allowed to send a chat action* this tick.
172
+ Return an empty sequence when the discussion phase is over.
173
+ """
174
+
175
+ @abstractmethod
176
+ def is_discussion_over(self, state: GameState) -> bool:
177
+ """Returns True if the entire discussion (including any preliminary phases like bidding) is complete."""
178
+ pass
179
+
180
+ @abstractmethod
181
+ def reset(self) -> None:
182
+ """Resets the protocol to its initial state."""
183
+ pass
184
+
185
+ def process_actions(self, actions: List[Action], expected_speakers: Sequence[PlayerID], state: GameState) -> None:
186
+ """
187
+ Processes a batch of actions. Depending on the protocol's state (e.g., bidding or chatting),
188
+ it will handle relevant actions (like BidAction or ChatAction) from expected_speakers.
189
+ """
190
+ for act in actions:
191
+ if isinstance(act, ChatAction):
192
+ all_player_ids = [p.id for p in state.players]
193
+ mentioned_ids = _extract_player_ids_from_string(act.message, all_player_ids)
194
+ if expected_speakers and act.actor_id in expected_speakers:
195
+ data = ChatDataEntry(
196
+ actor_id=act.actor_id,
197
+ message=act.message,
198
+ reasoning=act.reasoning,
199
+ mentioned_player_ids=mentioned_ids,
200
+ perceived_threat_level=act.perceived_threat_level,
201
+ action=act,
202
+ )
203
+ state.push_event(
204
+ description=f'Player "{act.actor_id}" (chat): {act.message}',
205
+ # Make public for general discussion
206
+ event_name=EventName.DISCUSSION,
207
+ public=True,
208
+ source=act.actor_id,
209
+ data=data,
210
+ )
211
+ else:
212
+ state.push_event(
213
+ description=f'Player "{act.actor_id}" (chat, out of turn): {act.message}',
214
+ event_name=EventName.DISCUSSION, # Or a specific "INVALID_CHAT" type
215
+ visible_to=[act.actor_id],
216
+ public=False,
217
+ source=act.actor_id,
218
+ )
219
+
220
+ def call_for_actions(self, speakers: Sequence[PlayerID]) -> List[str]:
221
+ """prepare moderator call for action for each player."""
222
+ return [f'Player "{speaker_id}", it is your turn to speak.' for speaker_id in speakers]
223
+
224
+ def prompt_speakers_for_tick(self, state: GameState, speakers: Sequence[PlayerID]) -> None:
225
+ """
226
+ Allows the protocol to make specific announcements or prompts to the current speakers for this tick.
227
+ This method is called by the Moderator after speakers_for_tick() returns a non-empty list of speakers,
228
+ and before process_actions().
229
+ Implementations should use state.push_event() to make announcements.
230
+ These announcements are typically visible only to the speakers, unless they are general status updates.
231
+ """
232
+ call_for_actions = self.call_for_actions(speakers)
233
+ for speaker_id, call_for_action in zip(speakers, call_for_actions):
234
+ data = RequestVillagerToSpeakDataEntry(action_json_schema=json.dumps(ChatAction.schema_for_player()))
235
+ state.push_event(
236
+ description=call_for_action,
237
+ event_name=EventName.CHAT_REQUEST,
238
+ public=False,
239
+ visible_to=[speaker_id],
240
+ data=data,
241
+ visible_in_ui=False,
242
+ )
@@ -0,0 +1,248 @@
1
+ from collections import Counter
2
+ from typing import Dict, List
3
+
4
+ from kaggle_environments.envs.werewolf.game.actions import Action, BidAction
5
+ from kaggle_environments.envs.werewolf.game.base import PlayerID
6
+ from kaggle_environments.envs.werewolf.game.consts import EventName
7
+ from kaggle_environments.envs.werewolf.game.protocols.base import BiddingProtocol
8
+ from kaggle_environments.envs.werewolf.game.records import BidDataEntry, ChatDataEntry
9
+ from kaggle_environments.envs.werewolf.game.states import GameState
10
+
11
+ from .factory import register_protocol
12
+
13
+
14
+ @register_protocol()
15
+ class SimpleBiddingProtocol(BiddingProtocol):
16
+ """
17
+ A straightforward bidding protocol where speaking priority is determined
18
+ solely by the bid amount.
19
+ - Agents bid with a numerical amount.
20
+ - Higher bids result in earlier speaking turns.
21
+ - Ties are broken deterministically by player ID (ascending).
22
+ """
23
+
24
+ def __init__(self):
25
+ self._bids: Dict[PlayerID, int] = {}
26
+ self._max_bid = 4
27
+ self.reset()
28
+
29
+ def reset(self) -> None:
30
+ """Resets the bids for a new round."""
31
+ self._bids = {}
32
+
33
+ @property
34
+ def display_name(self):
35
+ return "Simple Bidding"
36
+
37
+ @property
38
+ def rule(self) -> str:
39
+ """Provides a description of the bidding rules."""
40
+ return "\n".join(
41
+ (
42
+ "Players bid with an urgency level (0-4) to determine speaking order.",
43
+ "0: I would like to observe and listen for now.",
44
+ "1: I have some general thoughts to share with the group.",
45
+ "2: I have something critical and specific to contribute to this discussion.",
46
+ "3: It is absolutely urgent for me to speak next.",
47
+ "4: I must respond.",
48
+ "Higher bids speak earlier. Ties are broken by player name (A-Z).",
49
+ )
50
+ )
51
+
52
+ @property
53
+ def bids(self) -> Dict[PlayerID, int]:
54
+ """Returns a copy of the current bids."""
55
+ return dict(**self._bids)
56
+
57
+ def begin(self, state: GameState) -> None:
58
+ """Initializes a new bidding round."""
59
+ self.reset()
60
+
61
+ def accept(self, bid: BidAction, state: GameState) -> None:
62
+ """Accepts and records a single bid from a player."""
63
+ bid_amount = min(max(0, bid.amount), self._max_bid)
64
+ self._bids[bid.actor_id] = bid_amount
65
+
66
+ data = BidDataEntry(
67
+ actor_id=bid.actor_id,
68
+ reasoning=bid.reasoning,
69
+ perceived_threat_level=bid.perceived_threat_level,
70
+ bid_amount=bid_amount,
71
+ action=bid,
72
+ )
73
+ state.push_event(
74
+ description=f"Player {bid.actor_id} submitted a bid of {bid_amount}.",
75
+ event_name=EventName.BID_ACTION,
76
+ public=False, # Bids are private until the outcome is announced
77
+ visible_to=[bid.actor_id],
78
+ data=data,
79
+ source=bid.actor_id,
80
+ )
81
+
82
+ def process_incoming_bids(self, actions: List[Action], state: GameState) -> None:
83
+ """Processes a list of actions, handling any BidActions."""
84
+ for act in actions:
85
+ if isinstance(act, BidAction):
86
+ self.accept(act, state)
87
+
88
+ def is_finished(self, state: GameState) -> bool:
89
+ """
90
+ Checks if the bidding phase is complete (i.e., all alive players have bid).
91
+ """
92
+ return len(self._bids) >= len(state.alive_players())
93
+
94
+ def outcome(self, state: GameState) -> list[str]:
95
+ """
96
+ Determines the final speaking order based on bids.
97
+
98
+ Returns:
99
+ A list of player IDs sorted by bid (descending) and then player ID (ascending).
100
+ """
101
+ if not self._bids:
102
+ # If no bids were made, return alive players in their default order.
103
+ return sorted([p.id for p in state.alive_players()])
104
+
105
+ # Sort by bid amount (descending) and then by player ID (ascending) for tie-breaking.
106
+ sorted_bidders = sorted(self._bids.items(), key=lambda item: (-item[1], item[0]))
107
+ return [player_id for player_id, bid_amount in sorted_bidders]
108
+
109
+
110
+ @register_protocol()
111
+ class UrgencyBiddingProtocol(BiddingProtocol):
112
+ """
113
+ A bidding protocol based on the Werewolf Arena paper.
114
+ - Agents bid with an urgency level (0-4).
115
+ - Highest bidder wins.
116
+ - Ties are broken by prioritizing players mentioned in the previous turn.
117
+ """
118
+
119
+ @property
120
+ def display_name(self) -> str:
121
+ return "Urgency Bidding"
122
+
123
+ @property
124
+ def rule(self) -> str:
125
+ return "\n".join(
126
+ [
127
+ "Urgency-based bidding. Players bid with an urgency level (0-4).",
128
+ "0: I would like to observe and listen for now.",
129
+ "1: I have some general thoughts to share with the group.",
130
+ "2: I have something critical and specific to contribute to this discussion.",
131
+ "3: It is absolutely urgent for me to speak next.",
132
+ "4: Someone has addressed me directly and I must respond.",
133
+ "Highest bidder wins."
134
+ "Ties are broken by the following priority: (1) players mentioned in the previous turn's chat, "
135
+ "(2) the least spoken player, (3) round robin order of the player list.",
136
+ ]
137
+ )
138
+
139
+ @property
140
+ def bids(self) -> Dict[PlayerID, int]:
141
+ return dict(**self._bids)
142
+
143
+ def __init__(self):
144
+ self._bids: Dict[PlayerID, int] = {}
145
+ self._mentioned_last_turn: List[PlayerID] = []
146
+
147
+ def reset(self) -> None:
148
+ self._bids = {}
149
+ self._mentioned_last_turn = []
150
+
151
+ def begin(self, state: GameState) -> None:
152
+ """Called at the start of a bidding round to identify recently mentioned players."""
153
+ self.reset()
154
+ # Find the very last chat entry in the history to check for mentions
155
+ self._mentioned_last_turn, last_chat_message = self.get_last_mentioned(state)
156
+
157
+ if last_chat_message:
158
+ if self._mentioned_last_turn:
159
+ state.push_event(
160
+ description=f"Players mentioned last turn (priority in ties): {self._mentioned_last_turn}",
161
+ event_name=EventName.BIDDING_INFO,
162
+ public=True, # So everyone knows who has priority
163
+ )
164
+
165
+ def accept(self, bid: BidAction, state: GameState) -> None:
166
+ if 0 <= bid.amount <= 4:
167
+ self._bids[bid.actor_id] = bid.amount
168
+ data = BidDataEntry(
169
+ actor_id=bid.actor_id,
170
+ reasoning=bid.reasoning,
171
+ perceived_threat_level=bid.perceived_threat_level,
172
+ bid_amount=bid.amount,
173
+ action=bid,
174
+ )
175
+ state.push_event(
176
+ description=f"Player {bid.actor_id} submitted bid=({bid.amount}).",
177
+ event_name=EventName.BID_ACTION,
178
+ public=False,
179
+ visible_to=[bid.actor_id],
180
+ data=data,
181
+ source=bid.actor_id,
182
+ )
183
+ else:
184
+ # Invalid bid amount is treated as a bid of 0
185
+ self._bids[bid.actor_id] = 0
186
+ state.push_event(
187
+ description=f"Player {bid.actor_id} submitted an invalid bid amount ({bid.amount}). Treated as 0.",
188
+ event_name=EventName.ERROR,
189
+ public=False,
190
+ visible_to=[bid.actor_id],
191
+ )
192
+
193
+ def process_incoming_bids(self, actions: List[Action], state: GameState) -> None:
194
+ for act in actions:
195
+ if isinstance(act, BidAction):
196
+ self.accept(act, state)
197
+
198
+ def is_finished(self, state: GameState) -> bool:
199
+ # This bidding round is considered "finished" when all alive players have bid.
200
+ return len(self._bids) >= len(state.alive_players())
201
+
202
+ def outcome(self, state: GameState) -> list[str]:
203
+ if not self._bids:
204
+ # If no one bids, deterministically pick the first alive player to speak.
205
+ alive_players = state.alive_players()
206
+ return [alive_players[0].id] if alive_players else []
207
+
208
+ max_bid = max(self._bids.values())
209
+ highest_bidders = sorted([pid for pid, amt in self._bids.items() if amt == max_bid])
210
+
211
+ if len(highest_bidders) == 1:
212
+ return highest_bidders
213
+
214
+ # Tie-breaking logic
215
+ candidates = highest_bidders
216
+
217
+ # Rule 1: Players mentioned in the last turn
218
+ mentioned_in_tie = [pid for pid in candidates if pid in self._mentioned_last_turn]
219
+ if mentioned_in_tie:
220
+ candidates = mentioned_in_tie
221
+
222
+ if len(candidates) == 1:
223
+ return candidates
224
+
225
+ # Rule 2: The least spoken individual
226
+ speech_counts = Counter(
227
+ entry.data.actor_id
228
+ for day_events in state.history.values()
229
+ for entry in day_events
230
+ if entry.event_name == EventName.DISCUSSION and isinstance(entry.data, ChatDataEntry)
231
+ )
232
+
233
+ candidate_speech_counts = {pid: speech_counts.get(pid, 0) for pid in candidates}
234
+ min_spoken = min(candidate_speech_counts.values())
235
+ least_spoken_candidates = sorted([pid for pid, count in candidate_speech_counts.items() if count == min_spoken])
236
+
237
+ if len(least_spoken_candidates) == 1:
238
+ return least_spoken_candidates
239
+
240
+ candidates = least_spoken_candidates
241
+
242
+ # Rule 3: Round robin order of the player list in state
243
+ for pid in state.all_player_ids:
244
+ if pid in candidates:
245
+ return [pid]
246
+
247
+ # This part should be unreachable if candidates is a subset of all_player_ids
248
+ return [candidates[0]] if candidates else []