mail-swarms 1.3.2__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.
- mail/__init__.py +35 -0
- mail/api.py +1964 -0
- mail/cli.py +432 -0
- mail/client.py +1657 -0
- mail/config/__init__.py +8 -0
- mail/config/client.py +87 -0
- mail/config/server.py +165 -0
- mail/core/__init__.py +72 -0
- mail/core/actions.py +69 -0
- mail/core/agents.py +73 -0
- mail/core/message.py +366 -0
- mail/core/runtime.py +3537 -0
- mail/core/tasks.py +311 -0
- mail/core/tools.py +1206 -0
- mail/db/__init__.py +0 -0
- mail/db/init.py +182 -0
- mail/db/types.py +65 -0
- mail/db/utils.py +523 -0
- mail/examples/__init__.py +27 -0
- mail/examples/analyst_dummy/__init__.py +15 -0
- mail/examples/analyst_dummy/agent.py +136 -0
- mail/examples/analyst_dummy/prompts.py +44 -0
- mail/examples/consultant_dummy/__init__.py +15 -0
- mail/examples/consultant_dummy/agent.py +136 -0
- mail/examples/consultant_dummy/prompts.py +42 -0
- mail/examples/data_analysis/__init__.py +40 -0
- mail/examples/data_analysis/analyst/__init__.py +9 -0
- mail/examples/data_analysis/analyst/agent.py +67 -0
- mail/examples/data_analysis/analyst/prompts.py +53 -0
- mail/examples/data_analysis/processor/__init__.py +13 -0
- mail/examples/data_analysis/processor/actions.py +293 -0
- mail/examples/data_analysis/processor/agent.py +67 -0
- mail/examples/data_analysis/processor/prompts.py +48 -0
- mail/examples/data_analysis/reporter/__init__.py +10 -0
- mail/examples/data_analysis/reporter/actions.py +187 -0
- mail/examples/data_analysis/reporter/agent.py +67 -0
- mail/examples/data_analysis/reporter/prompts.py +49 -0
- mail/examples/data_analysis/statistics/__init__.py +18 -0
- mail/examples/data_analysis/statistics/actions.py +343 -0
- mail/examples/data_analysis/statistics/agent.py +67 -0
- mail/examples/data_analysis/statistics/prompts.py +60 -0
- mail/examples/mafia/__init__.py +0 -0
- mail/examples/mafia/game.py +1537 -0
- mail/examples/mafia/narrator_tools.py +396 -0
- mail/examples/mafia/personas.py +240 -0
- mail/examples/mafia/prompts.py +489 -0
- mail/examples/mafia/roles.py +147 -0
- mail/examples/mafia/spec.md +350 -0
- mail/examples/math_dummy/__init__.py +23 -0
- mail/examples/math_dummy/actions.py +252 -0
- mail/examples/math_dummy/agent.py +136 -0
- mail/examples/math_dummy/prompts.py +46 -0
- mail/examples/math_dummy/types.py +5 -0
- mail/examples/research/__init__.py +39 -0
- mail/examples/research/researcher/__init__.py +9 -0
- mail/examples/research/researcher/agent.py +67 -0
- mail/examples/research/researcher/prompts.py +54 -0
- mail/examples/research/searcher/__init__.py +10 -0
- mail/examples/research/searcher/actions.py +324 -0
- mail/examples/research/searcher/agent.py +67 -0
- mail/examples/research/searcher/prompts.py +53 -0
- mail/examples/research/summarizer/__init__.py +18 -0
- mail/examples/research/summarizer/actions.py +255 -0
- mail/examples/research/summarizer/agent.py +67 -0
- mail/examples/research/summarizer/prompts.py +55 -0
- mail/examples/research/verifier/__init__.py +10 -0
- mail/examples/research/verifier/actions.py +337 -0
- mail/examples/research/verifier/agent.py +67 -0
- mail/examples/research/verifier/prompts.py +52 -0
- mail/examples/supervisor/__init__.py +11 -0
- mail/examples/supervisor/agent.py +4 -0
- mail/examples/supervisor/prompts.py +93 -0
- mail/examples/support/__init__.py +33 -0
- mail/examples/support/classifier/__init__.py +10 -0
- mail/examples/support/classifier/actions.py +307 -0
- mail/examples/support/classifier/agent.py +68 -0
- mail/examples/support/classifier/prompts.py +56 -0
- mail/examples/support/coordinator/__init__.py +9 -0
- mail/examples/support/coordinator/agent.py +67 -0
- mail/examples/support/coordinator/prompts.py +48 -0
- mail/examples/support/faq/__init__.py +10 -0
- mail/examples/support/faq/actions.py +182 -0
- mail/examples/support/faq/agent.py +67 -0
- mail/examples/support/faq/prompts.py +42 -0
- mail/examples/support/sentiment/__init__.py +15 -0
- mail/examples/support/sentiment/actions.py +341 -0
- mail/examples/support/sentiment/agent.py +67 -0
- mail/examples/support/sentiment/prompts.py +54 -0
- mail/examples/weather_dummy/__init__.py +23 -0
- mail/examples/weather_dummy/actions.py +75 -0
- mail/examples/weather_dummy/agent.py +136 -0
- mail/examples/weather_dummy/prompts.py +35 -0
- mail/examples/weather_dummy/types.py +5 -0
- mail/factories/__init__.py +27 -0
- mail/factories/action.py +223 -0
- mail/factories/base.py +1531 -0
- mail/factories/supervisor.py +241 -0
- mail/net/__init__.py +7 -0
- mail/net/registry.py +712 -0
- mail/net/router.py +728 -0
- mail/net/server_utils.py +114 -0
- mail/net/types.py +247 -0
- mail/server.py +1605 -0
- mail/stdlib/__init__.py +0 -0
- mail/stdlib/anthropic/__init__.py +0 -0
- mail/stdlib/fs/__init__.py +15 -0
- mail/stdlib/fs/actions.py +209 -0
- mail/stdlib/http/__init__.py +19 -0
- mail/stdlib/http/actions.py +333 -0
- mail/stdlib/interswarm/__init__.py +11 -0
- mail/stdlib/interswarm/actions.py +208 -0
- mail/stdlib/mcp/__init__.py +19 -0
- mail/stdlib/mcp/actions.py +294 -0
- mail/stdlib/openai/__init__.py +13 -0
- mail/stdlib/openai/agents.py +451 -0
- mail/summarizer.py +234 -0
- mail/swarms_json/__init__.py +27 -0
- mail/swarms_json/types.py +87 -0
- mail/swarms_json/utils.py +255 -0
- mail/url_scheme.py +51 -0
- mail/utils/__init__.py +53 -0
- mail/utils/auth.py +194 -0
- mail/utils/context.py +17 -0
- mail/utils/logger.py +73 -0
- mail/utils/openai.py +212 -0
- mail/utils/parsing.py +89 -0
- mail/utils/serialize.py +292 -0
- mail/utils/store.py +49 -0
- mail/utils/string_builder.py +119 -0
- mail/utils/version.py +20 -0
- mail_swarms-1.3.2.dist-info/METADATA +237 -0
- mail_swarms-1.3.2.dist-info/RECORD +137 -0
- mail_swarms-1.3.2.dist-info/WHEEL +4 -0
- mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
- mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
- mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
- mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
import uuid
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
import litellm
|
|
9
|
+
import rich
|
|
10
|
+
|
|
11
|
+
from mail.api import MAILAgentTemplate, MAILMessage, MAILSwarm, MAILSwarmTemplate
|
|
12
|
+
from mail.examples.mafia.narrator_tools import NarratorError, get_narrator_actions
|
|
13
|
+
from mail.examples.mafia.personas import PERSONAS, Persona
|
|
14
|
+
from mail.examples.mafia.prompts import (
|
|
15
|
+
create_agent_system_prompt,
|
|
16
|
+
create_narrator_system_prompt,
|
|
17
|
+
)
|
|
18
|
+
from mail.examples.mafia.roles import (
|
|
19
|
+
ALL_ROLES,
|
|
20
|
+
Role,
|
|
21
|
+
calculate_roles,
|
|
22
|
+
)
|
|
23
|
+
from mail.factories.base import base_agent_factory
|
|
24
|
+
from mail.utils import get_version
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GamePhase(Enum):
|
|
28
|
+
SETUP = "setup"
|
|
29
|
+
NIGHT = "night"
|
|
30
|
+
DAY_NARRATION = "day_narration"
|
|
31
|
+
DISCUSSION = "discussion"
|
|
32
|
+
NOMINATION = "nomination"
|
|
33
|
+
DEFENSE = "defense"
|
|
34
|
+
TRIAL = "trial"
|
|
35
|
+
GALLOWS = "gallows"
|
|
36
|
+
GAME_OVER = "game_over"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WinCondition(Enum):
|
|
40
|
+
TOWN_WINS = "town_wins"
|
|
41
|
+
MAFIA_WINS = "mafia_wins"
|
|
42
|
+
JESTER_WINS = "jester_wins"
|
|
43
|
+
NONE = "none"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
NON_REASONING_LLMS = ["openai/gpt-4o", "openai/gpt-4.1"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Agent:
|
|
51
|
+
persona: Persona
|
|
52
|
+
role: Role
|
|
53
|
+
llm: str
|
|
54
|
+
alive: bool = True
|
|
55
|
+
|
|
56
|
+
def build_agent_template(self) -> MAILAgentTemplate:
|
|
57
|
+
system = create_agent_system_prompt(self.persona, self.role)
|
|
58
|
+
reasoning_effort = (
|
|
59
|
+
"medium" if not litellm.supports_reasoning(self.llm) else None
|
|
60
|
+
)
|
|
61
|
+
return MAILAgentTemplate(
|
|
62
|
+
name=self.persona.name,
|
|
63
|
+
factory=base_agent_factory,
|
|
64
|
+
comm_targets=["Narrator"],
|
|
65
|
+
actions=[],
|
|
66
|
+
agent_params={
|
|
67
|
+
"llm": self.llm,
|
|
68
|
+
"system": system,
|
|
69
|
+
"user_token": "dummy",
|
|
70
|
+
"use_proxy": False,
|
|
71
|
+
"_debug_include_mail_tools": False,
|
|
72
|
+
"reasoning_effort": reasoning_effort,
|
|
73
|
+
"tool_format": "responses"
|
|
74
|
+
if self.llm.startswith("openai/")
|
|
75
|
+
else "completions",
|
|
76
|
+
"stream_tokens": True,
|
|
77
|
+
},
|
|
78
|
+
enable_entrypoint=True,
|
|
79
|
+
enable_interswarm=False,
|
|
80
|
+
can_complete_tasks=True,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_narrator_template(
|
|
85
|
+
game: "Game", player_names: list[str], llm: str = "openai/gpt-5-mini"
|
|
86
|
+
) -> MAILAgentTemplate:
|
|
87
|
+
system = create_narrator_system_prompt()
|
|
88
|
+
actions = get_narrator_actions(game)
|
|
89
|
+
reasoning_effort = "medium" if not litellm.supports_reasoning(llm) else None
|
|
90
|
+
return MAILAgentTemplate(
|
|
91
|
+
name="Narrator",
|
|
92
|
+
factory=base_agent_factory,
|
|
93
|
+
comm_targets=player_names,
|
|
94
|
+
actions=actions,
|
|
95
|
+
agent_params={
|
|
96
|
+
"llm": llm,
|
|
97
|
+
"system": system,
|
|
98
|
+
"user_token": "dummy",
|
|
99
|
+
"use_proxy": False,
|
|
100
|
+
"_debug_include_mail_tools": False,
|
|
101
|
+
"reasoning_effort": reasoning_effort,
|
|
102
|
+
"tool_format": "responses" if llm.startswith("openai/") else "completions",
|
|
103
|
+
"stream_tokens": True,
|
|
104
|
+
},
|
|
105
|
+
enable_entrypoint=True,
|
|
106
|
+
enable_interswarm=False,
|
|
107
|
+
can_complete_tasks=True,
|
|
108
|
+
tool_format="responses" if llm.startswith("openai/") else "completions",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_agent_swarm(agents: list[MAILAgentTemplate]) -> MAILSwarmTemplate:
|
|
113
|
+
actions = []
|
|
114
|
+
for agent in agents:
|
|
115
|
+
actions.extend(agent.actions)
|
|
116
|
+
return MAILSwarmTemplate(
|
|
117
|
+
name="swarm",
|
|
118
|
+
version=get_version(),
|
|
119
|
+
agents=agents,
|
|
120
|
+
actions=actions,
|
|
121
|
+
entrypoint=agents[0].name,
|
|
122
|
+
enable_interswarm=False,
|
|
123
|
+
breakpoint_tools=[],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Game:
|
|
129
|
+
players: list[Agent] = field(default_factory=list)
|
|
130
|
+
_swarm: MAILSwarm | None = None
|
|
131
|
+
task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
132
|
+
day_number: int = 0
|
|
133
|
+
phase: GamePhase = GamePhase.SETUP
|
|
134
|
+
narrator_llm: str = "openai/gpt-5-mini"
|
|
135
|
+
dynamic_ctx_ratio: float = 0.75
|
|
136
|
+
|
|
137
|
+
# Night state
|
|
138
|
+
protected_player: str | None = None
|
|
139
|
+
night_deaths: list[str] = field(default_factory=list)
|
|
140
|
+
mafia_votes: dict[str, str] = field(default_factory=dict) # mafia_name -> target
|
|
141
|
+
investigation_results: dict[str, str] = field(
|
|
142
|
+
default_factory=dict
|
|
143
|
+
) # player -> role (cumulative)
|
|
144
|
+
|
|
145
|
+
# Day state
|
|
146
|
+
nominees: list[str] = field(default_factory=list)
|
|
147
|
+
pending_nominations: dict[str, str] = field(
|
|
148
|
+
default_factory=dict
|
|
149
|
+
) # nominee -> nominator (awaiting second)
|
|
150
|
+
current_speaker: str | None = None
|
|
151
|
+
condemned: str | None = None
|
|
152
|
+
discussion_ended: bool = False
|
|
153
|
+
trial_tie_nominees: list[str] = field(default_factory=list) # For revotes
|
|
154
|
+
|
|
155
|
+
# Vote tracking (for current vote)
|
|
156
|
+
current_votes_for: list[str] = field(default_factory=list)
|
|
157
|
+
current_votes_against: list[str] = field(default_factory=list)
|
|
158
|
+
|
|
159
|
+
# Win state
|
|
160
|
+
winner: WinCondition = WinCondition.NONE
|
|
161
|
+
jester_executed: str | None = None # Name of jester if they won
|
|
162
|
+
|
|
163
|
+
# Interactive mode
|
|
164
|
+
interactive: bool = False
|
|
165
|
+
_step_count: int = 0
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def swarm(self) -> MAILSwarm:
|
|
169
|
+
if self._swarm is None:
|
|
170
|
+
raise ValueError("Game not started")
|
|
171
|
+
return self._swarm
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def n_players(self) -> int:
|
|
175
|
+
return len(self.players)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def n_alive(self) -> int:
|
|
179
|
+
return sum(player.alive for player in self.players)
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def create(
|
|
183
|
+
n: int, valid_llms: list[str] | None = None, narrator_llm: str | None = None
|
|
184
|
+
) -> "Game":
|
|
185
|
+
roles = calculate_roles(n)
|
|
186
|
+
personas = random.sample(PERSONAS, n)
|
|
187
|
+
players: list[Agent] = []
|
|
188
|
+
temp_llms = valid_llms.copy() if valid_llms is not None else None
|
|
189
|
+
for r, c in roles.items():
|
|
190
|
+
for _ in range(c):
|
|
191
|
+
persona = personas.pop()
|
|
192
|
+
llm = "openai/gpt-5-mini"
|
|
193
|
+
if temp_llms is not None:
|
|
194
|
+
assert valid_llms is not None, (
|
|
195
|
+
"valid_llms is None but temp_llms is not None"
|
|
196
|
+
)
|
|
197
|
+
if len(temp_llms) == 0:
|
|
198
|
+
temp_llms = valid_llms.copy()
|
|
199
|
+
llm = random.choice(temp_llms)
|
|
200
|
+
temp_llms.remove(llm)
|
|
201
|
+
players.append(
|
|
202
|
+
Agent(
|
|
203
|
+
persona=persona,
|
|
204
|
+
role=ALL_ROLES[r],
|
|
205
|
+
llm=llm,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
narrator_llm = narrator_llm or "openai/gpt-5-mini"
|
|
209
|
+
g = Game(players=players, narrator_llm=narrator_llm)
|
|
210
|
+
agents = [agent.build_agent_template() for agent in players]
|
|
211
|
+
agents.append(
|
|
212
|
+
build_narrator_template(
|
|
213
|
+
g,
|
|
214
|
+
[agent.persona.name for agent in players],
|
|
215
|
+
narrator_llm,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
template = build_agent_swarm(agents)
|
|
219
|
+
swarm = template.instantiate({"user_token": "dummy"}, "MafiaGame")
|
|
220
|
+
asyncio.create_task(swarm.run_continuous(mode="manual"))
|
|
221
|
+
g._swarm = swarm
|
|
222
|
+
return g
|
|
223
|
+
|
|
224
|
+
# ==================== Tool Callbacks ====================
|
|
225
|
+
|
|
226
|
+
def doctor_protect(self, target_name: str) -> str:
|
|
227
|
+
"""Record the doctor's protection target for the night."""
|
|
228
|
+
player = self.get_player_by_name(target_name)
|
|
229
|
+
if player is None:
|
|
230
|
+
raise NarratorError(f"Player '{target_name}' does not exist")
|
|
231
|
+
if not player.alive:
|
|
232
|
+
raise NarratorError(f"Player '{target_name}' is dead")
|
|
233
|
+
|
|
234
|
+
doctor = self.get_doctor()
|
|
235
|
+
if doctor and target_name == doctor.persona.name:
|
|
236
|
+
raise NarratorError("Doctor cannot protect themselves")
|
|
237
|
+
|
|
238
|
+
self.protected_player = target_name
|
|
239
|
+
return f"Doctor has protected {target_name} for the night."
|
|
240
|
+
|
|
241
|
+
def detective_investigate(self, target_name: str) -> str:
|
|
242
|
+
"""Investigate a player and return their role."""
|
|
243
|
+
player = self.get_player_by_name(target_name)
|
|
244
|
+
if player is None:
|
|
245
|
+
raise NarratorError(f"Player '{target_name}' does not exist")
|
|
246
|
+
if not player.alive:
|
|
247
|
+
raise NarratorError(f"Player '{target_name}' is dead")
|
|
248
|
+
|
|
249
|
+
detective = self.get_detective()
|
|
250
|
+
if detective and target_name == detective.persona.name:
|
|
251
|
+
raise NarratorError("Detective cannot investigate themselves")
|
|
252
|
+
|
|
253
|
+
role_name = player.role.name
|
|
254
|
+
self.investigation_results[target_name] = role_name
|
|
255
|
+
return role_name
|
|
256
|
+
|
|
257
|
+
def mafia_vote_kill(self, mafia_name: str, target_name: str) -> str:
|
|
258
|
+
"""Record a mafia member's vote to kill a target.
|
|
259
|
+
|
|
260
|
+
When all mafia have voted, also returns tie information if there is one.
|
|
261
|
+
"""
|
|
262
|
+
mafia_player = self.get_player_by_name(mafia_name)
|
|
263
|
+
if mafia_player is None:
|
|
264
|
+
raise NarratorError(f"Player '{mafia_name}' does not exist")
|
|
265
|
+
if not mafia_player.alive:
|
|
266
|
+
raise NarratorError(f"Mafia member '{mafia_name}' is dead")
|
|
267
|
+
if mafia_player.role.name != "Mafia":
|
|
268
|
+
raise NarratorError(f"Player '{mafia_name}' is not Mafia")
|
|
269
|
+
|
|
270
|
+
target = self.get_player_by_name(target_name)
|
|
271
|
+
if target is None:
|
|
272
|
+
raise NarratorError(f"Target '{target_name}' does not exist")
|
|
273
|
+
if not target.alive:
|
|
274
|
+
raise NarratorError(f"Target '{target_name}' is dead")
|
|
275
|
+
if target.role.name == "Mafia":
|
|
276
|
+
raise NarratorError("Mafia cannot target other Mafia members")
|
|
277
|
+
|
|
278
|
+
self.mafia_votes[mafia_name] = target_name
|
|
279
|
+
|
|
280
|
+
# Check if all mafia have voted
|
|
281
|
+
alive_mafia = self.get_mafia_members()
|
|
282
|
+
all_voted = all(m.persona.name in self.mafia_votes for m in alive_mafia)
|
|
283
|
+
|
|
284
|
+
if all_voted:
|
|
285
|
+
# Check for tie
|
|
286
|
+
is_tie, tied_targets = self.check_mafia_vote_tie()
|
|
287
|
+
if is_tie:
|
|
288
|
+
# Clear votes for revote
|
|
289
|
+
self.mafia_votes = {}
|
|
290
|
+
return (
|
|
291
|
+
f"Mafia member {mafia_name} voted to kill {target_name}.\n\n"
|
|
292
|
+
f"=== TIE DETECTED ===\n"
|
|
293
|
+
f"All Mafia have voted, but there is a tie between: {', '.join(tied_targets)}\n"
|
|
294
|
+
f"The Mafia must revote. Prompt each Mafia member to vote again, "
|
|
295
|
+
f"choosing ONLY from the tied targets: {', '.join(tied_targets)}"
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
kill_target = self.get_mafia_kill_target()
|
|
299
|
+
return (
|
|
300
|
+
f"Mafia member {mafia_name} voted to kill {target_name}.\n\n"
|
|
301
|
+
f"All Mafia votes recorded. Target: {kill_target}\n\n"
|
|
302
|
+
"The night phase is ending. The system will determine the outcome.\n"
|
|
303
|
+
"Narrate the conclusion of the night phase. You will reveal night deaths in the morning, so don't reveal them here."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return f"Mafia member {mafia_name} voted to kill {target_name}"
|
|
307
|
+
|
|
308
|
+
def select_speaker(self, player_name: str) -> str:
|
|
309
|
+
"""Select the next player to speak during discussion."""
|
|
310
|
+
player = self.get_player_by_name(player_name)
|
|
311
|
+
if player is None:
|
|
312
|
+
raise NarratorError(f"Player '{player_name}' does not exist")
|
|
313
|
+
if not player.alive:
|
|
314
|
+
raise NarratorError(f"Player '{player_name}' is dead")
|
|
315
|
+
|
|
316
|
+
self.current_speaker = player_name
|
|
317
|
+
return f"Selected {player_name} to speak."
|
|
318
|
+
|
|
319
|
+
def end_discussion(self) -> str:
|
|
320
|
+
"""End the discussion phase and transition to town hall."""
|
|
321
|
+
self.discussion_ended = True
|
|
322
|
+
return "Discussion ended. Narrate the end of the discussion phase, and the transition to town hall. You will intro the town hall phase in your next message, not this one."
|
|
323
|
+
|
|
324
|
+
def add_nominee(self, player_name: str, nominator_name: str) -> str:
|
|
325
|
+
"""Add a player to the nominees list.
|
|
326
|
+
|
|
327
|
+
Two-phase process:
|
|
328
|
+
1. First call: Records nomination as pending, awaiting a second
|
|
329
|
+
2. Second call (different nominator): Confirms the nomination
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
player_name: The player being nominated
|
|
333
|
+
nominator_name: The player making the nomination OR seconding it
|
|
334
|
+
"""
|
|
335
|
+
player = self.get_player_by_name(player_name)
|
|
336
|
+
if player is None:
|
|
337
|
+
raise NarratorError(f"Player '{player_name}' does not exist")
|
|
338
|
+
if not player.alive:
|
|
339
|
+
raise NarratorError(f"Player '{player_name}' is dead")
|
|
340
|
+
|
|
341
|
+
nominator = self.get_player_by_name(nominator_name)
|
|
342
|
+
if nominator is None:
|
|
343
|
+
raise NarratorError(f"Nominator '{nominator_name}' does not exist")
|
|
344
|
+
if not nominator.alive:
|
|
345
|
+
raise NarratorError(f"Nominator '{nominator_name}' is dead")
|
|
346
|
+
|
|
347
|
+
if player_name == nominator_name:
|
|
348
|
+
raise NarratorError("A player cannot nominate themselves")
|
|
349
|
+
|
|
350
|
+
if player_name in self.nominees:
|
|
351
|
+
raise NarratorError(
|
|
352
|
+
f"Player '{player_name}' is already a confirmed nominee"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if len(self.nominees) >= 3:
|
|
356
|
+
raise NarratorError("Maximum of 3 nominees reached")
|
|
357
|
+
|
|
358
|
+
# Check if this is a pending nomination being seconded
|
|
359
|
+
if player_name in self.pending_nominations:
|
|
360
|
+
original_nominator = self.pending_nominations[player_name]
|
|
361
|
+
if nominator_name == original_nominator:
|
|
362
|
+
raise NarratorError(
|
|
363
|
+
f"The original nominator ({nominator_name}) cannot second their own nomination"
|
|
364
|
+
)
|
|
365
|
+
# Confirm the nomination
|
|
366
|
+
del self.pending_nominations[player_name]
|
|
367
|
+
self.nominees.append(player_name)
|
|
368
|
+
return (
|
|
369
|
+
f"=== NOMINATION CONFIRMED ===\n"
|
|
370
|
+
f"{nominator_name} has seconded the nomination!\n"
|
|
371
|
+
f"{player_name} is now officially nominated for execution.\n"
|
|
372
|
+
f"Current nominees: {', '.join(self.nominees)}\n"
|
|
373
|
+
+ (
|
|
374
|
+
"Narrate the end of the nomination phase, and the transition to the defense phase. You will intro the defense phase in your next message, not this one."
|
|
375
|
+
if len(self.nominees) >= 3
|
|
376
|
+
else ""
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
# New nomination - add to pending
|
|
381
|
+
self.pending_nominations[player_name] = nominator_name
|
|
382
|
+
return (
|
|
383
|
+
f"{nominator_name} has nominated {player_name} for execution.\n"
|
|
384
|
+
f"Awaiting a second from another player.\n"
|
|
385
|
+
f"Ask players if they want to second this nomination. "
|
|
386
|
+
f"If someone says yes, call add_nominee({player_name}, seconder_name) to confirm."
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def record_vote(self, for_names: list[str], against_names: list[str]) -> str:
|
|
390
|
+
"""Record the results of a vote."""
|
|
391
|
+
# Validate all voters exist and are alive
|
|
392
|
+
all_voters = for_names + against_names
|
|
393
|
+
for name in all_voters:
|
|
394
|
+
player = self.get_player_by_name(name)
|
|
395
|
+
if player is None:
|
|
396
|
+
raise NarratorError(f"Voter '{name}' does not exist")
|
|
397
|
+
if not player.alive:
|
|
398
|
+
raise NarratorError(f"Voter '{name}' is dead")
|
|
399
|
+
|
|
400
|
+
# Check for duplicates
|
|
401
|
+
if len(all_voters) != len(set(all_voters)):
|
|
402
|
+
raise NarratorError("A voter appears in both for and against lists")
|
|
403
|
+
|
|
404
|
+
self.current_votes_for = list(for_names)
|
|
405
|
+
self.current_votes_against = list(against_names)
|
|
406
|
+
|
|
407
|
+
for_count = len(for_names)
|
|
408
|
+
against_count = len(against_names)
|
|
409
|
+
return f"Vote recorded: {for_count} for, {against_count} against."
|
|
410
|
+
|
|
411
|
+
def record_trial_vote(self, votes: dict[str, str]) -> str:
|
|
412
|
+
"""
|
|
413
|
+
Record trial votes where each player votes for a nominee.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
votes: Dict mapping voter_name -> nominee_name they voted for
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Result message indicating the outcome or if a revote is needed.
|
|
420
|
+
"""
|
|
421
|
+
from collections import Counter
|
|
422
|
+
|
|
423
|
+
# Validate all voters exist and are alive
|
|
424
|
+
for voter_name in votes.keys():
|
|
425
|
+
voter = self.get_player_by_name(voter_name)
|
|
426
|
+
if voter is None:
|
|
427
|
+
raise NarratorError(f"Voter '{voter_name}' does not exist")
|
|
428
|
+
if not voter.alive:
|
|
429
|
+
raise NarratorError(f"Voter '{voter_name}' is dead")
|
|
430
|
+
|
|
431
|
+
# Validate all nominees exist and are in the nominees list
|
|
432
|
+
for nominee_name in votes.values():
|
|
433
|
+
if nominee_name not in self.nominees:
|
|
434
|
+
raise NarratorError(
|
|
435
|
+
f"'{nominee_name}' is not a valid nominee. Current nominees: {', '.join(self.nominees)}"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Tally votes
|
|
439
|
+
vote_counts: Counter[str] = Counter(votes.values())
|
|
440
|
+
max_votes = max(vote_counts.values())
|
|
441
|
+
top_nominees = [
|
|
442
|
+
name for name, count in vote_counts.items() if count == max_votes
|
|
443
|
+
]
|
|
444
|
+
|
|
445
|
+
# Build vote breakdown
|
|
446
|
+
breakdown_lines = []
|
|
447
|
+
for nominee in self.nominees:
|
|
448
|
+
count = vote_counts.get(nominee, 0)
|
|
449
|
+
voters_for = [v for v, n in votes.items() if n == nominee]
|
|
450
|
+
breakdown_lines.append(
|
|
451
|
+
f" {nominee}: {count} votes ({', '.join(voters_for) if voters_for else 'none'})"
|
|
452
|
+
)
|
|
453
|
+
breakdown = "\n".join(breakdown_lines)
|
|
454
|
+
|
|
455
|
+
if len(top_nominees) == 1:
|
|
456
|
+
# Clear winner
|
|
457
|
+
self.condemned = top_nominees[0]
|
|
458
|
+
return (
|
|
459
|
+
f"=== TRIAL VOTE RESULT ===\n"
|
|
460
|
+
f"{breakdown}\n\n"
|
|
461
|
+
f"{self.condemned} has been condemned to the gallows with {max_votes} votes.\n"
|
|
462
|
+
f"Announce this dramatically and narrate the transition to the gallows phase. You will intro the gallows phase in your next message, not this one."
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
# Tie - need revote
|
|
466
|
+
self.trial_tie_nominees = top_nominees
|
|
467
|
+
return (
|
|
468
|
+
f"=== TIE DETECTED ===\n"
|
|
469
|
+
f"{breakdown}\n\n"
|
|
470
|
+
f"There is a tie between: {', '.join(top_nominees)} (each with {max_votes} votes).\n"
|
|
471
|
+
f"A revote is required. Ask each player to vote again, choosing ONLY from the tied nominees: {', '.join(top_nominees)}"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# ==================== Helper Methods ====================
|
|
475
|
+
|
|
476
|
+
def get_player_by_name(self, name: str) -> Agent | None:
|
|
477
|
+
"""Get a player by their name."""
|
|
478
|
+
for player in self.players:
|
|
479
|
+
if player.persona.name == name:
|
|
480
|
+
return player
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
def get_alive_players(self) -> list[Agent]:
|
|
484
|
+
"""Get all alive players."""
|
|
485
|
+
players = [p for p in self.players if p.alive]
|
|
486
|
+
random.shuffle(players)
|
|
487
|
+
return players
|
|
488
|
+
|
|
489
|
+
def get_alive_names(self) -> list[str]:
|
|
490
|
+
"""Get names of all alive players."""
|
|
491
|
+
return [p.persona.name for p in self.get_alive_players()]
|
|
492
|
+
|
|
493
|
+
def get_dead_players(self) -> list[Agent]:
|
|
494
|
+
"""Get all dead players."""
|
|
495
|
+
return [p for p in self.players if not p.alive]
|
|
496
|
+
|
|
497
|
+
def get_players_by_role(self, role_name: str) -> list[Agent]:
|
|
498
|
+
"""Get all players with a specific role."""
|
|
499
|
+
return [p for p in self.players if p.role.name == role_name]
|
|
500
|
+
|
|
501
|
+
def get_alive_players_by_role(self, role_name: str) -> list[Agent]:
|
|
502
|
+
"""Get all alive players with a specific role."""
|
|
503
|
+
return [p for p in self.get_alive_players() if p.role.name == role_name]
|
|
504
|
+
|
|
505
|
+
def get_doctor(self) -> Agent | None:
|
|
506
|
+
"""Get the doctor if alive."""
|
|
507
|
+
doctors = self.get_alive_players_by_role("Doctor")
|
|
508
|
+
return doctors[0] if doctors else None
|
|
509
|
+
|
|
510
|
+
def get_detective(self) -> Agent | None:
|
|
511
|
+
"""Get the detective if alive."""
|
|
512
|
+
detectives = self.get_alive_players_by_role("Detective")
|
|
513
|
+
return detectives[0] if detectives else None
|
|
514
|
+
|
|
515
|
+
def get_mafia_members(self) -> list[Agent]:
|
|
516
|
+
"""Get all alive mafia members."""
|
|
517
|
+
mafia = self.get_alive_players_by_role("Mafia")
|
|
518
|
+
random.shuffle(mafia)
|
|
519
|
+
return mafia
|
|
520
|
+
|
|
521
|
+
def get_mafia_names(self) -> list[str]:
|
|
522
|
+
"""Get names of all alive mafia members."""
|
|
523
|
+
return [p.persona.name for p in self.get_mafia_members()]
|
|
524
|
+
|
|
525
|
+
def get_non_mafia_players(self) -> list[Agent]:
|
|
526
|
+
"""Get all alive non-mafia players."""
|
|
527
|
+
return [p for p in self.get_alive_players() if p.role.name != "Mafia"]
|
|
528
|
+
|
|
529
|
+
def get_player_role(self, name: str) -> str:
|
|
530
|
+
"""Get the role of a player by name."""
|
|
531
|
+
player = self.get_player_by_name(name)
|
|
532
|
+
return player.role.name if player else "Unknown"
|
|
533
|
+
|
|
534
|
+
def kill_player(self, name: str) -> None:
|
|
535
|
+
"""Mark a player as dead."""
|
|
536
|
+
player = self.get_player_by_name(name)
|
|
537
|
+
if player:
|
|
538
|
+
player.alive = False
|
|
539
|
+
|
|
540
|
+
def get_role_assignments_str(self) -> str:
|
|
541
|
+
"""Get a formatted string of all role assignments (for narrator)."""
|
|
542
|
+
lines = []
|
|
543
|
+
for player in self.players:
|
|
544
|
+
lines.append(f"- {player.persona.name}:")
|
|
545
|
+
lines.append(f" Bio: {player.persona.short_desc}")
|
|
546
|
+
lines.append(f" Role: {player.role.name}")
|
|
547
|
+
return "\n".join(lines)
|
|
548
|
+
|
|
549
|
+
def check_mafia_vote_tie(self) -> tuple[bool, list[str]]:
|
|
550
|
+
"""
|
|
551
|
+
Check if there's a tie in mafia votes.
|
|
552
|
+
Returns (is_tie, tied_targets).
|
|
553
|
+
"""
|
|
554
|
+
if not self.mafia_votes:
|
|
555
|
+
return False, []
|
|
556
|
+
|
|
557
|
+
vote_counts = Counter(self.mafia_votes.values())
|
|
558
|
+
if not vote_counts:
|
|
559
|
+
return False, []
|
|
560
|
+
|
|
561
|
+
max_votes = max(vote_counts.values())
|
|
562
|
+
targets_with_max = [t for t, c in vote_counts.items() if c == max_votes]
|
|
563
|
+
|
|
564
|
+
# Tie if multiple targets have the same max votes
|
|
565
|
+
is_tie = len(targets_with_max) > 1
|
|
566
|
+
return is_tie, targets_with_max
|
|
567
|
+
|
|
568
|
+
def get_mafia_kill_target(self) -> str | None:
|
|
569
|
+
"""
|
|
570
|
+
Get the mafia's kill target based on votes.
|
|
571
|
+
Returns None if no clear target (tie or no votes).
|
|
572
|
+
"""
|
|
573
|
+
if not self.mafia_votes:
|
|
574
|
+
return None
|
|
575
|
+
|
|
576
|
+
vote_counts = Counter(self.mafia_votes.values())
|
|
577
|
+
max_votes = max(vote_counts.values())
|
|
578
|
+
targets_with_max = [t for t, c in vote_counts.items() if c == max_votes]
|
|
579
|
+
|
|
580
|
+
if len(targets_with_max) == 1:
|
|
581
|
+
return targets_with_max[0]
|
|
582
|
+
return None # Tie
|
|
583
|
+
|
|
584
|
+
def resolve_night_actions(self) -> None:
|
|
585
|
+
"""Resolve night actions and determine deaths."""
|
|
586
|
+
self.night_deaths = []
|
|
587
|
+
|
|
588
|
+
# Get the mafia's target (should already be resolved with no ties)
|
|
589
|
+
target = self.get_mafia_kill_target()
|
|
590
|
+
if target:
|
|
591
|
+
# Check if protected
|
|
592
|
+
if target != self.protected_player:
|
|
593
|
+
self.kill_player(target)
|
|
594
|
+
self.night_deaths.append(target)
|
|
595
|
+
|
|
596
|
+
# Reset night state for next night
|
|
597
|
+
self.protected_player = None
|
|
598
|
+
self.mafia_votes = {}
|
|
599
|
+
|
|
600
|
+
def check_win_condition(self) -> WinCondition:
|
|
601
|
+
"""Check if any win condition is met."""
|
|
602
|
+
# Check if jester was executed during day
|
|
603
|
+
if self.jester_executed:
|
|
604
|
+
self.winner = WinCondition.JESTER_WINS
|
|
605
|
+
return WinCondition.JESTER_WINS
|
|
606
|
+
|
|
607
|
+
mafia_count = len(self.get_mafia_members())
|
|
608
|
+
non_mafia_count = len(self.get_non_mafia_players())
|
|
609
|
+
|
|
610
|
+
# All mafia dead - town wins
|
|
611
|
+
if mafia_count == 0:
|
|
612
|
+
self.winner = WinCondition.TOWN_WINS
|
|
613
|
+
return WinCondition.TOWN_WINS
|
|
614
|
+
|
|
615
|
+
# Mafia >= non-mafia - mafia wins
|
|
616
|
+
if mafia_count >= non_mafia_count:
|
|
617
|
+
self.winner = WinCondition.MAFIA_WINS
|
|
618
|
+
return WinCondition.MAFIA_WINS
|
|
619
|
+
|
|
620
|
+
return WinCondition.NONE
|
|
621
|
+
|
|
622
|
+
def format_night_deaths(self) -> str:
|
|
623
|
+
"""Format night deaths for narration."""
|
|
624
|
+
if not self.night_deaths:
|
|
625
|
+
return "No one died during the night."
|
|
626
|
+
elif len(self.night_deaths) == 1:
|
|
627
|
+
return f"{self.night_deaths[0]} was killed during the night."
|
|
628
|
+
else:
|
|
629
|
+
names = ", ".join(self.night_deaths[:-1]) + f" and {self.night_deaths[-1]}"
|
|
630
|
+
return f"{names} were killed during the night."
|
|
631
|
+
|
|
632
|
+
def reset_day_state(self) -> None:
|
|
633
|
+
"""Reset state for a new day."""
|
|
634
|
+
self.nominees = []
|
|
635
|
+
self.pending_nominations = {}
|
|
636
|
+
self.current_speaker = None
|
|
637
|
+
self.condemned = None
|
|
638
|
+
self.discussion_ended = False
|
|
639
|
+
self.trial_tie_nominees = []
|
|
640
|
+
self.current_votes_for = []
|
|
641
|
+
self.current_votes_against = []
|
|
642
|
+
|
|
643
|
+
# ==================== Agent Stepping ====================
|
|
644
|
+
|
|
645
|
+
def _interactive_wait(
|
|
646
|
+
self, agent_name: str, payload: str = "", is_narrator: bool = False
|
|
647
|
+
) -> str:
|
|
648
|
+
"""Wait for user input in interactive mode."""
|
|
649
|
+
|
|
650
|
+
self._step_count += 1
|
|
651
|
+
phase_str = self.phase.value if self.phase else "setup"
|
|
652
|
+
if not is_narrator:
|
|
653
|
+
agent_obj = self.get_player_by_name(agent_name)
|
|
654
|
+
assert agent_obj is not None, (
|
|
655
|
+
f"Agent '{agent_name}' not found during interactive wait. This is very bad."
|
|
656
|
+
)
|
|
657
|
+
agent_llm = agent_obj.llm
|
|
658
|
+
agent_role: str = agent_obj.role.name
|
|
659
|
+
if agent_role == "Mafia":
|
|
660
|
+
agent_role = "[bold red]Mafia[/bold red]"
|
|
661
|
+
elif agent_role == "Detective":
|
|
662
|
+
agent_role = "[bold blue]Detective[/bold blue]"
|
|
663
|
+
elif agent_role == "Doctor":
|
|
664
|
+
agent_role = "[bold blue]Doctor[/bold blue]"
|
|
665
|
+
elif agent_role == "Villager":
|
|
666
|
+
agent_role = "[bold green]Villager[/bold green]"
|
|
667
|
+
elif agent_role == "Jester":
|
|
668
|
+
agent_role = "[bold yellow]Jester[/bold yellow]"
|
|
669
|
+
else:
|
|
670
|
+
agent_role = "[bold white]Unknown[/bold white]"
|
|
671
|
+
agent_persona = agent_obj.persona.short_desc
|
|
672
|
+
else:
|
|
673
|
+
agent_llm = self.narrator_llm
|
|
674
|
+
agent_role = ""
|
|
675
|
+
rich.print(f"\n\n[bold purple]{'=' * 61}[/bold purple]\n\n")
|
|
676
|
+
rich.print(f"\n{'=' * 61}")
|
|
677
|
+
rich.print(
|
|
678
|
+
f"[Step {self._step_count}] Phase: {phase_str} | Day: {self.day_number}"
|
|
679
|
+
)
|
|
680
|
+
rich.print(f"About to step: {agent_role} '{agent_name}' (model: {agent_llm})")
|
|
681
|
+
rich.print(f"Persona: {agent_persona}") if not is_narrator else None
|
|
682
|
+
rich.print(f"[bold cyan]{'=' * 26} PAYLOAD {'=' * 26}[/bold cyan]")
|
|
683
|
+
rich.print(payload)
|
|
684
|
+
rich.print(f"[bold cyan]{'=' * 26} END PAYLOAD {'=' * 26}[/bold cyan]")
|
|
685
|
+
rich.print(f"{'=' * 61}")
|
|
686
|
+
if self.interactive:
|
|
687
|
+
addn_payload = input(
|
|
688
|
+
"Enter additional payload (or press Enter to continue): "
|
|
689
|
+
)
|
|
690
|
+
return addn_payload
|
|
691
|
+
else:
|
|
692
|
+
return ""
|
|
693
|
+
|
|
694
|
+
def _print_response(
|
|
695
|
+
self, agent_name: str, response: MAILMessage, is_narrator: bool = False
|
|
696
|
+
) -> None:
|
|
697
|
+
"""Print the response message body in interactive mode."""
|
|
698
|
+
|
|
699
|
+
agent_type = "Narrator" if is_narrator else "Agent"
|
|
700
|
+
|
|
701
|
+
# Extract body from the message
|
|
702
|
+
body = ""
|
|
703
|
+
if "message" in response:
|
|
704
|
+
msg = response["message"]
|
|
705
|
+
if "body" in msg:
|
|
706
|
+
body = msg["body"] # type: ignore[typeddict-item]
|
|
707
|
+
|
|
708
|
+
rich.print(f"\n{'─' * 60}")
|
|
709
|
+
rich.print(f"Response from {agent_type} '{agent_name}':")
|
|
710
|
+
rich.print(f"{'─' * 60}")
|
|
711
|
+
rich.print(body)
|
|
712
|
+
rich.print(f"{'─' * 60}\n")
|
|
713
|
+
|
|
714
|
+
async def step_narrator(self, payload: str = "") -> MAILMessage:
|
|
715
|
+
"""Step the narrator with a broadcast response."""
|
|
716
|
+
await asyncio.sleep(1)
|
|
717
|
+
await self.swarm.await_queue_empty()
|
|
718
|
+
addn_payload = self._interactive_wait(
|
|
719
|
+
"Narrator", payload=payload, is_narrator=True
|
|
720
|
+
)
|
|
721
|
+
payload += addn_payload
|
|
722
|
+
response = await self.swarm.manual_step(
|
|
723
|
+
task_id=self.task_id,
|
|
724
|
+
target="Narrator",
|
|
725
|
+
response_targets=["all"],
|
|
726
|
+
response_type="broadcast",
|
|
727
|
+
payload=payload,
|
|
728
|
+
dynamic_ctx_ratio=self.dynamic_ctx_ratio,
|
|
729
|
+
_llm=self.narrator_llm,
|
|
730
|
+
_system=create_narrator_system_prompt(),
|
|
731
|
+
)
|
|
732
|
+
await asyncio.sleep(1)
|
|
733
|
+
return response
|
|
734
|
+
|
|
735
|
+
async def step_agent(
|
|
736
|
+
self,
|
|
737
|
+
agent_name: str,
|
|
738
|
+
broadcast: bool = False,
|
|
739
|
+
targets: list[str] | None = None,
|
|
740
|
+
payload: str = "",
|
|
741
|
+
) -> MAILMessage:
|
|
742
|
+
"""
|
|
743
|
+
Step an agent.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
agent_name: Name of the agent to step
|
|
747
|
+
broadcast: If True, broadcast response to all agents
|
|
748
|
+
targets: If not broadcast, specific targets for the response
|
|
749
|
+
payload: Additional context to provide to the agent
|
|
750
|
+
"""
|
|
751
|
+
await self.swarm.await_queue_empty()
|
|
752
|
+
addn_payload = self._interactive_wait(
|
|
753
|
+
agent_name, payload=payload, is_narrator=False
|
|
754
|
+
)
|
|
755
|
+
payload += addn_payload
|
|
756
|
+
a = self.get_player_by_name(agent_name)
|
|
757
|
+
assert a is not None
|
|
758
|
+
|
|
759
|
+
if broadcast:
|
|
760
|
+
response = await self.swarm.manual_step(
|
|
761
|
+
task_id=self.task_id,
|
|
762
|
+
target=agent_name,
|
|
763
|
+
response_targets=["all"],
|
|
764
|
+
response_type="broadcast",
|
|
765
|
+
payload=payload,
|
|
766
|
+
dynamic_ctx_ratio=self.dynamic_ctx_ratio,
|
|
767
|
+
_llm=a.llm,
|
|
768
|
+
_system=create_agent_system_prompt(a.persona, a.role),
|
|
769
|
+
)
|
|
770
|
+
else:
|
|
771
|
+
response_targets = targets or ["Narrator"]
|
|
772
|
+
response = await self.swarm.manual_step(
|
|
773
|
+
task_id=self.task_id,
|
|
774
|
+
target=agent_name,
|
|
775
|
+
response_targets=response_targets,
|
|
776
|
+
response_type="response" if len(response_targets) == 1 else "broadcast",
|
|
777
|
+
payload=payload,
|
|
778
|
+
dynamic_ctx_ratio=self.dynamic_ctx_ratio,
|
|
779
|
+
_llm=a.llm,
|
|
780
|
+
_system=create_agent_system_prompt(a.persona, a.role),
|
|
781
|
+
)
|
|
782
|
+
await asyncio.sleep(1)
|
|
783
|
+
return response
|
|
784
|
+
|
|
785
|
+
# ==================== Game Initialization ====================
|
|
786
|
+
|
|
787
|
+
async def start_game(self) -> None:
|
|
788
|
+
"""Initialize the game with narrator intro and role assignments."""
|
|
789
|
+
self.phase = GamePhase.SETUP
|
|
790
|
+
|
|
791
|
+
# Build initial message to start the game
|
|
792
|
+
player_names = [p.persona.name for p in self.players]
|
|
793
|
+
init_msg = self.swarm.build_message(
|
|
794
|
+
subject="::init::",
|
|
795
|
+
body=f"<system>Game starting with players: {', '.join(player_names)}</system>",
|
|
796
|
+
targets=["all"],
|
|
797
|
+
sender_type="user",
|
|
798
|
+
type="broadcast",
|
|
799
|
+
task_id=self.task_id,
|
|
800
|
+
)
|
|
801
|
+
await self.swarm.submit_message_nowait(init_msg)
|
|
802
|
+
|
|
803
|
+
# Wait for message to be processed
|
|
804
|
+
await asyncio.sleep(0.5)
|
|
805
|
+
|
|
806
|
+
# Step narrator to welcome players and set the scene
|
|
807
|
+
await self.step_narrator(
|
|
808
|
+
payload=f"""
|
|
809
|
+
=== GAME SETUP ===
|
|
810
|
+
You are the Narrator for this Mafia game.
|
|
811
|
+
|
|
812
|
+
PLAYERS ({len(self.players)}): {", ".join(player_names)}
|
|
813
|
+
|
|
814
|
+
ROLE ASSIGNMENTS (SECRET - only you know this):
|
|
815
|
+
{self.get_role_assignments_str()}
|
|
816
|
+
|
|
817
|
+
Your task: Welcome the players to the game. Set the scene for the story.
|
|
818
|
+
Create an atmospheric introduction to the town and the looming threat of the Mafia.
|
|
819
|
+
Don't state the players' bios word-for-word, but you may use elements of them. Each player has a unique background and personality, of which the bio is just a short description.
|
|
820
|
+
Do NOT reveal anyone's role. Just set the stage dramatically.
|
|
821
|
+
What you say is heard by EVERYONE, no matter what, so you must be careful not to reveal any information that would give away the role of any player.
|
|
822
|
+
"""
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# Give each player their role assignment info
|
|
826
|
+
role_assignments: dict[str, str] = {}
|
|
827
|
+
for player in self.players:
|
|
828
|
+
role_info = f"""
|
|
829
|
+
=== YOUR ROLE ASSIGNMENT ===
|
|
830
|
+
You are {player.persona.name}.
|
|
831
|
+
Your role is: {player.role.name}
|
|
832
|
+
|
|
833
|
+
{player.role.bio}
|
|
834
|
+
|
|
835
|
+
Win Condition: {player.role.wincon}
|
|
836
|
+
"""
|
|
837
|
+
if player.role.name == "Mafia":
|
|
838
|
+
other_mafia = [
|
|
839
|
+
p.persona.name
|
|
840
|
+
for p in self.get_mafia_members()
|
|
841
|
+
if p.persona.name != player.persona.name
|
|
842
|
+
]
|
|
843
|
+
if other_mafia:
|
|
844
|
+
role_info += (
|
|
845
|
+
f"\nYour fellow Mafia members: {', '.join(other_mafia)}"
|
|
846
|
+
)
|
|
847
|
+
else:
|
|
848
|
+
role_info += "\nYou are the only Mafia member."
|
|
849
|
+
|
|
850
|
+
role_assignments[player.persona.name] = role_info
|
|
851
|
+
for player_name, role_info in role_assignments.items():
|
|
852
|
+
await self.swarm.submit_message_nowait(
|
|
853
|
+
self.swarm.build_message(
|
|
854
|
+
subject="::role_assignment::",
|
|
855
|
+
body=role_info,
|
|
856
|
+
targets=[player_name],
|
|
857
|
+
sender_type="user",
|
|
858
|
+
type="request",
|
|
859
|
+
task_id=self.task_id,
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
# ==================== Night Phase ====================
|
|
864
|
+
|
|
865
|
+
async def run_night_phase(self) -> None:
|
|
866
|
+
"""Execute the night phase: doctor, detective, and mafia actions."""
|
|
867
|
+
self.phase = GamePhase.NIGHT
|
|
868
|
+
self.protected_player = None
|
|
869
|
+
self.mafia_votes = {}
|
|
870
|
+
|
|
871
|
+
alive_names = self.get_alive_names()
|
|
872
|
+
mafia_names = self.get_mafia_names()
|
|
873
|
+
|
|
874
|
+
# Step narrator to announce night and prompt doctor
|
|
875
|
+
doctor = self.get_doctor()
|
|
876
|
+
detective = self.get_detective()
|
|
877
|
+
|
|
878
|
+
night_intro = f"""
|
|
879
|
+
=== NIGHT {self.day_number} ===
|
|
880
|
+
The night falls over the town. It's time for night actions.
|
|
881
|
+
|
|
882
|
+
Alive players: {", ".join(alive_names)}
|
|
883
|
+
"""
|
|
884
|
+
if doctor:
|
|
885
|
+
night_intro += f"""
|
|
886
|
+
First, prompt the Doctor ({doctor.persona.name}) to choose who to protect.
|
|
887
|
+
Address them directly, AS THEIR ROLE, NOT THEIR NAME, and ask who they want to protect tonight.
|
|
888
|
+
Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
|
|
889
|
+
"""
|
|
890
|
+
else:
|
|
891
|
+
night_intro += "\nThe Doctor is dead. Skip to the Detective."
|
|
892
|
+
|
|
893
|
+
await self.step_narrator(payload=night_intro)
|
|
894
|
+
|
|
895
|
+
# Doctor's turn (if alive)
|
|
896
|
+
if doctor:
|
|
897
|
+
other_players = [n for n in alive_names if n != doctor.persona.name]
|
|
898
|
+
await self.step_agent(
|
|
899
|
+
doctor.persona.name,
|
|
900
|
+
broadcast=False,
|
|
901
|
+
targets=["Narrator"],
|
|
902
|
+
payload=f"""
|
|
903
|
+
[PRIVATE - Only the Narrator can see this message]
|
|
904
|
+
You are speaking privately with the Narrator. Other players cannot hear you.
|
|
905
|
+
|
|
906
|
+
Night {self.day_number} - You are the Doctor.
|
|
907
|
+
Choose one player to protect tonight. If the Mafia targets them, they will survive.
|
|
908
|
+
You CANNOT protect yourself.
|
|
909
|
+
|
|
910
|
+
Available targets: {", ".join(other_players)}
|
|
911
|
+
|
|
912
|
+
Your response must end with: "I protect [player_name]"
|
|
913
|
+
""",
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Step narrator to process doctor's choice and prompt detective
|
|
917
|
+
detective_prompt = ""
|
|
918
|
+
if detective:
|
|
919
|
+
detective_prompt = f"""
|
|
920
|
+
Now prompt the Detective ({detective.persona.name}) to choose who to investigate.
|
|
921
|
+
Use the doctor_protect tool to record the doctor's choice first.
|
|
922
|
+
Then address the Detective directly, AS THEIR ROLE, NOT THEIR NAME.
|
|
923
|
+
Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
|
|
924
|
+
"""
|
|
925
|
+
else:
|
|
926
|
+
detective_prompt = f"""
|
|
927
|
+
The Detective is dead. Use the doctor_protect tool to record the doctor's choice.
|
|
928
|
+
Then prompt the Mafia members, AS THEIR ROLES, NOT THEIR NAMES, to vote on their target.
|
|
929
|
+
|
|
930
|
+
Mafia members: {", ".join(mafia_names)}
|
|
931
|
+
Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
|
|
932
|
+
"""
|
|
933
|
+
await self.step_narrator(payload=detective_prompt)
|
|
934
|
+
|
|
935
|
+
# Detective's turn (if alive)
|
|
936
|
+
if detective:
|
|
937
|
+
other_players = [n for n in alive_names if n != detective.persona.name]
|
|
938
|
+
|
|
939
|
+
# Build investigation history for detective
|
|
940
|
+
history_str = ""
|
|
941
|
+
if self.investigation_results:
|
|
942
|
+
history_lines = [
|
|
943
|
+
f"- {name}: {role}"
|
|
944
|
+
for name, role in self.investigation_results.items()
|
|
945
|
+
]
|
|
946
|
+
history_str = "\nYour previous investigations:\n" + "\n".join(
|
|
947
|
+
history_lines
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
await self.step_agent(
|
|
951
|
+
detective.persona.name,
|
|
952
|
+
broadcast=False,
|
|
953
|
+
targets=["Narrator"],
|
|
954
|
+
payload=f"""
|
|
955
|
+
[PRIVATE - Only the Narrator can see this message]
|
|
956
|
+
You are speaking privately with the Narrator. Other players cannot hear you.
|
|
957
|
+
|
|
958
|
+
Night {self.day_number} - You are the Detective.
|
|
959
|
+
Choose one player to investigate. You will learn their true role.
|
|
960
|
+
You CANNOT investigate yourself.
|
|
961
|
+
{history_str}
|
|
962
|
+
|
|
963
|
+
Available targets: {", ".join(other_players)}
|
|
964
|
+
|
|
965
|
+
Your response must end with: "I investigate [player_name]"
|
|
966
|
+
""",
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
# Step narrator to process detective's choice (uses detective_investigate tool)
|
|
970
|
+
# Store investigation count before to detect when tool is called
|
|
971
|
+
prev_investigation_count = len(self.investigation_results)
|
|
972
|
+
|
|
973
|
+
await self.step_narrator(
|
|
974
|
+
payload=f"""
|
|
975
|
+
Use the detective_investigate tool to record the detective's choice.
|
|
976
|
+
The system will privately inform the detective of the result.
|
|
977
|
+
Then prompt the Mafia members, AS THEIR ROLES, NOT THEIR NAMES, to vote on their target.
|
|
978
|
+
|
|
979
|
+
Mafia members: {", ".join(mafia_names)}
|
|
980
|
+
Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role, even if it was just revealed by the detetive.
|
|
981
|
+
"""
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
# After narrator processes, send investigation result privately to detective
|
|
985
|
+
# Check if a new investigation was recorded
|
|
986
|
+
if len(self.investigation_results) > prev_investigation_count:
|
|
987
|
+
# Find the most recent investigation (last key added)
|
|
988
|
+
investigated_name = list(self.investigation_results.keys())[-1]
|
|
989
|
+
investigated_role = self.investigation_results[investigated_name]
|
|
990
|
+
|
|
991
|
+
# Manually submit private message to detective with result
|
|
992
|
+
investigation_msg = self.swarm.build_message(
|
|
993
|
+
subject="Investigation Result",
|
|
994
|
+
body=f"""[PRIVATE - Investigation Result]
|
|
995
|
+
Your investigation reveals that {investigated_name} is a {investigated_role}.""",
|
|
996
|
+
targets=[detective.persona.name],
|
|
997
|
+
sender_type="user",
|
|
998
|
+
type="request",
|
|
999
|
+
task_id=self.task_id,
|
|
1000
|
+
)
|
|
1001
|
+
await self.swarm.submit_message_nowait(investigation_msg)
|
|
1002
|
+
|
|
1003
|
+
# Mafia's turn
|
|
1004
|
+
non_mafia_names = [p.persona.name for p in self.get_non_mafia_players()]
|
|
1005
|
+
|
|
1006
|
+
# If no doctor or detective, prompt mafia directly
|
|
1007
|
+
if not doctor and not detective:
|
|
1008
|
+
await self.step_narrator(
|
|
1009
|
+
payload=f"""
|
|
1010
|
+
=== NIGHT {self.day_number} - MAFIA TURN ===
|
|
1011
|
+
Prompt the Mafia members, AS THEIR ROLES, NOT THEIR NAMES, to vote on their kill target.
|
|
1012
|
+
Mafia members: {", ".join(mafia_names)}
|
|
1013
|
+
Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
|
|
1014
|
+
"""
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
# Mafia voting - narrator handles revotes via tool response
|
|
1018
|
+
await self._run_mafia_vote(mafia_names, non_mafia_names)
|
|
1019
|
+
|
|
1020
|
+
# Resolve night actions (determine deaths)
|
|
1021
|
+
self.resolve_night_actions()
|
|
1022
|
+
|
|
1023
|
+
async def _run_mafia_vote(
|
|
1024
|
+
self,
|
|
1025
|
+
mafia_names: list[str],
|
|
1026
|
+
valid_targets: list[str],
|
|
1027
|
+
) -> None:
|
|
1028
|
+
"""Run mafia voting. Narrator will handle revotes via tool response."""
|
|
1029
|
+
for mafia in self.get_mafia_members():
|
|
1030
|
+
other_mafia = [n for n in mafia_names if n != mafia.persona.name]
|
|
1031
|
+
fellow_mafia_str = (
|
|
1032
|
+
f"Your fellow Mafia: {', '.join(other_mafia)}"
|
|
1033
|
+
if other_mafia
|
|
1034
|
+
else "You are the only Mafia member."
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
payload = f"""
|
|
1038
|
+
[PRIVATE - Only the Narrator and fellow Mafia can see this message]
|
|
1039
|
+
You are speaking privately with the Narrator and your Mafia allies.
|
|
1040
|
+
|
|
1041
|
+
Night {self.day_number} - You are Mafia.
|
|
1042
|
+
{fellow_mafia_str}
|
|
1043
|
+
|
|
1044
|
+
Vote for one player to kill tonight. The player with the most Mafia votes will die
|
|
1045
|
+
(unless protected by the Doctor).
|
|
1046
|
+
|
|
1047
|
+
Potential targets: {", ".join(valid_targets)}
|
|
1048
|
+
|
|
1049
|
+
Your response may include some discussion, or the reason you chose your target. But you must end with: "I vote to kill [player_name]"
|
|
1050
|
+
"""
|
|
1051
|
+
|
|
1052
|
+
await self.step_agent(
|
|
1053
|
+
mafia.persona.name,
|
|
1054
|
+
broadcast=False,
|
|
1055
|
+
targets=["Narrator"] + other_mafia, # Mafia can see each other's votes
|
|
1056
|
+
payload=payload,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
# Step narrator to record all mafia votes
|
|
1060
|
+
# If there's a tie, the last mafia_vote_kill tool response will include
|
|
1061
|
+
# tie info and instruct the narrator to prompt for revotes
|
|
1062
|
+
await self.step_narrator(
|
|
1063
|
+
payload=f"""
|
|
1064
|
+
Use the mafia_vote_kill tool for each Mafia member's vote.
|
|
1065
|
+
Mafia members: {", ".join(mafia_names)}
|
|
1066
|
+
|
|
1067
|
+
If the tool response indicates a TIE, you must prompt each Mafia member to revote
|
|
1068
|
+
among only the tied targets, then record those votes with mafia_vote_kill again.
|
|
1069
|
+
Don't reveal the names of the tied targets, because the mafia already know them and everyone can see your messages.
|
|
1070
|
+
Your message will be heard by everyone, so be sure not to say anything that would reveal anyone's role.
|
|
1071
|
+
"""
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
# ==================== Day Phase ====================
|
|
1075
|
+
|
|
1076
|
+
async def run_day_phase(self) -> None:
|
|
1077
|
+
"""Execute the full day phase."""
|
|
1078
|
+
self.reset_day_state()
|
|
1079
|
+
|
|
1080
|
+
await self.run_death_narration()
|
|
1081
|
+
|
|
1082
|
+
# Check win condition after deaths
|
|
1083
|
+
if self.check_win_condition() != WinCondition.NONE:
|
|
1084
|
+
return
|
|
1085
|
+
|
|
1086
|
+
await self.run_discussion()
|
|
1087
|
+
await self.run_town_hall()
|
|
1088
|
+
|
|
1089
|
+
async def run_death_narration(self) -> None:
|
|
1090
|
+
"""Narrator announces night deaths."""
|
|
1091
|
+
self.phase = GamePhase.DAY_NARRATION
|
|
1092
|
+
|
|
1093
|
+
deaths_info = self.format_night_deaths()
|
|
1094
|
+
protected_info = ""
|
|
1095
|
+
if self.protected_player and self.protected_player not in self.night_deaths:
|
|
1096
|
+
# Someone was protected but we don't reveal this publicly
|
|
1097
|
+
protected_info = (
|
|
1098
|
+
f"\n(Private note: {self.protected_player} was protected by the Doctor)"
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
await self.step_narrator(
|
|
1102
|
+
payload=f"""
|
|
1103
|
+
=== DAY {self.day_number} ===
|
|
1104
|
+
{deaths_info}
|
|
1105
|
+
{protected_info}
|
|
1106
|
+
|
|
1107
|
+
Narrate the morning dramatically. Describe the scene as the town awakens.
|
|
1108
|
+
If someone died, create an atmospheric death scene (but do NOT reveal their role).
|
|
1109
|
+
If no one died, describe the tense relief as everyone realizes they survived.
|
|
1110
|
+
|
|
1111
|
+
Alive players: {", ".join(self.get_alive_names())}
|
|
1112
|
+
"""
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
async def run_discussion(self) -> None:
|
|
1116
|
+
"""Run the discussion phase where narrator selects speakers."""
|
|
1117
|
+
self.phase = GamePhase.DISCUSSION
|
|
1118
|
+
self.discussion_ended = False
|
|
1119
|
+
speakers_so_far: list[str] = []
|
|
1120
|
+
|
|
1121
|
+
while not self.discussion_ended:
|
|
1122
|
+
# Step narrator to select speaker or end discussion
|
|
1123
|
+
await self.step_narrator(
|
|
1124
|
+
payload=f"""
|
|
1125
|
+
=== DISCUSSION PHASE ===
|
|
1126
|
+
You control who speaks. Use select_speaker(player_name) to call on someone,
|
|
1127
|
+
or use end_discussion() to move to Town Hall voting.
|
|
1128
|
+
|
|
1129
|
+
Alive players: {", ".join(self.get_alive_names())}
|
|
1130
|
+
Players who have spoken: {", ".join(speakers_so_far) if speakers_so_far else "None yet"}
|
|
1131
|
+
|
|
1132
|
+
Choose wisely to create interesting discussions and drama.
|
|
1133
|
+
Reminder: No formal nominations are made during discussion. Those have to wait until the town hall phase.
|
|
1134
|
+
When you feel enough discussion has happened, call end_discussion().
|
|
1135
|
+
"""
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
if self.discussion_ended:
|
|
1139
|
+
break
|
|
1140
|
+
|
|
1141
|
+
# Step the selected speaker to broadcast their thoughts
|
|
1142
|
+
if self.current_speaker:
|
|
1143
|
+
speakers_so_far.append(self.current_speaker)
|
|
1144
|
+
await self.step_agent(
|
|
1145
|
+
self.current_speaker,
|
|
1146
|
+
broadcast=True,
|
|
1147
|
+
payload=f"""
|
|
1148
|
+
The Narrator has called on you to speak.
|
|
1149
|
+
Share your thoughts, suspicions, theories, or defend yourself.
|
|
1150
|
+
Everyone can hear what you say.
|
|
1151
|
+
|
|
1152
|
+
Alive players: {", ".join(self.get_alive_names())}
|
|
1153
|
+
""",
|
|
1154
|
+
)
|
|
1155
|
+
self.current_speaker = None
|
|
1156
|
+
|
|
1157
|
+
async def run_town_hall(self) -> None:
|
|
1158
|
+
"""Run the town hall: nominations, defense, trial, and gallows."""
|
|
1159
|
+
await self.run_nomination_phase()
|
|
1160
|
+
|
|
1161
|
+
if not self.nominees:
|
|
1162
|
+
# No nominees, skip to next night
|
|
1163
|
+
await self.step_narrator(
|
|
1164
|
+
payload="""
|
|
1165
|
+
No one was nominated for execution today. The town disperses uneasily.
|
|
1166
|
+
Announce that night is falling and the day ends without an execution.
|
|
1167
|
+
Do not narrate the start of the next night phase, or if any win condition is met. You will intro the next phase (or narrate the end of the game) in your next message, not this one.
|
|
1168
|
+
"""
|
|
1169
|
+
)
|
|
1170
|
+
return
|
|
1171
|
+
|
|
1172
|
+
if len(self.nominees) >= 2:
|
|
1173
|
+
await self.run_defense_phase()
|
|
1174
|
+
|
|
1175
|
+
await self.run_trial_phase()
|
|
1176
|
+
|
|
1177
|
+
if self.condemned:
|
|
1178
|
+
await self.run_gallows_phase()
|
|
1179
|
+
|
|
1180
|
+
async def run_nomination_phase(self) -> None:
|
|
1181
|
+
"""Run the nomination phase where players nominate others."""
|
|
1182
|
+
self.phase = GamePhase.NOMINATION
|
|
1183
|
+
self.nominees = []
|
|
1184
|
+
self.pending_nominations = {}
|
|
1185
|
+
|
|
1186
|
+
await self.step_narrator(
|
|
1187
|
+
payload=f"""
|
|
1188
|
+
=== TOWN HALL - NOMINATION PHASE ===
|
|
1189
|
+
Each player may nominate one other player for execution, or pass.
|
|
1190
|
+
After each nomination, ONE other player must second it to confirm.
|
|
1191
|
+
Maximum 3 nominees allowed.
|
|
1192
|
+
|
|
1193
|
+
Alive players: {", ".join(self.get_alive_names())}
|
|
1194
|
+
|
|
1195
|
+
Announce the start of nominations and explain the process to the players.
|
|
1196
|
+
"""
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
for player in self.get_alive_players():
|
|
1200
|
+
if len(self.nominees) >= 3:
|
|
1201
|
+
break
|
|
1202
|
+
|
|
1203
|
+
if player.persona.name in self.nominees:
|
|
1204
|
+
continue
|
|
1205
|
+
|
|
1206
|
+
# Player nominates or passes (broadcast - public)
|
|
1207
|
+
await self.step_agent(
|
|
1208
|
+
player.persona.name,
|
|
1209
|
+
broadcast=True,
|
|
1210
|
+
payload=f"""
|
|
1211
|
+
=== NOMINATION PHASE ===
|
|
1212
|
+
You may nominate one player for execution, or pass.
|
|
1213
|
+
Current confirmed nominees: {self.nominees if self.nominees else "None yet"}
|
|
1214
|
+
|
|
1215
|
+
Your response must end with: "I nominate [player_name]" or "I pass"
|
|
1216
|
+
""",
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
# Step narrator to process nomination
|
|
1220
|
+
await self.step_narrator(
|
|
1221
|
+
payload=f"""
|
|
1222
|
+
Process {player.persona.name}'s response.
|
|
1223
|
+
|
|
1224
|
+
If they nominated someone:
|
|
1225
|
+
1. Use add_nominee(player_name, {player.persona.name}) to record the nomination
|
|
1226
|
+
2. The tool will tell you the nomination is pending and needs a second
|
|
1227
|
+
3. Ask OTHER players (not the nominator) if anyone wants to second
|
|
1228
|
+
4. When someone says yes, call add_nominee(player_name, seconder_name) again to confirm
|
|
1229
|
+
|
|
1230
|
+
If they passed, acknowledge it, and the system will automatically move to the next player who hasn't themselves been confirmed as a nominee.
|
|
1231
|
+
|
|
1232
|
+
Current confirmed nominees: {self.nominees}
|
|
1233
|
+
Pending nominations: {list(self.pending_nominations.keys()) if self.pending_nominations else "None"}
|
|
1234
|
+
"""
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
# If there's a pending nomination, ask for seconds (PUBLIC - broadcast)
|
|
1238
|
+
if self.pending_nominations:
|
|
1239
|
+
pending_nominee = list(self.pending_nominations.keys())[0]
|
|
1240
|
+
original_nominator = self.pending_nominations[pending_nominee]
|
|
1241
|
+
|
|
1242
|
+
other_players = [
|
|
1243
|
+
p
|
|
1244
|
+
for p in self.get_alive_players()
|
|
1245
|
+
if p.persona.name != original_nominator
|
|
1246
|
+
and p.persona.name not in self.nominees
|
|
1247
|
+
and p.persona.name != pending_nominee
|
|
1248
|
+
]
|
|
1249
|
+
|
|
1250
|
+
for voter in other_players:
|
|
1251
|
+
# If already confirmed by a previous second, skip remaining
|
|
1252
|
+
if pending_nominee not in self.pending_nominations:
|
|
1253
|
+
break
|
|
1254
|
+
|
|
1255
|
+
await self.step_agent(
|
|
1256
|
+
voter.persona.name,
|
|
1257
|
+
broadcast=True, # PUBLIC - everyone can see
|
|
1258
|
+
payload=f"""
|
|
1259
|
+
=== SECONDING ===
|
|
1260
|
+
{original_nominator} has nominated {pending_nominee} for execution.
|
|
1261
|
+
Do you want to second this nomination?
|
|
1262
|
+
|
|
1263
|
+
Respond publicly: "I second the nomination" or "I do not second"
|
|
1264
|
+
""",
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
# Narrator checks if they seconded and confirms if so
|
|
1268
|
+
await self.step_narrator(
|
|
1269
|
+
payload=f"""
|
|
1270
|
+
Did {voter.persona.name} second the nomination of {pending_nominee}?
|
|
1271
|
+
|
|
1272
|
+
If yes: Call add_nominee({pending_nominee}, {voter.persona.name}) to confirm the nomination.
|
|
1273
|
+
The tool will return a confirmation message. Announce this to everyone.
|
|
1274
|
+
|
|
1275
|
+
If no: Acknowledge it, and the system will automatically move to the next player for seconding.
|
|
1276
|
+
Only players who haven't themselves been confirmed as a nominee can second. A player cannot second their own nomination.
|
|
1277
|
+
|
|
1278
|
+
Pending nominations: {list(self.pending_nominations.keys()) if self.pending_nominations else "None"}
|
|
1279
|
+
Confirmed nominees: {self.nominees}
|
|
1280
|
+
"""
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
# If nomination wasn't seconded by anyone, it fails
|
|
1284
|
+
if pending_nominee in self.pending_nominations:
|
|
1285
|
+
del self.pending_nominations[pending_nominee]
|
|
1286
|
+
await self.step_narrator(
|
|
1287
|
+
payload=f"""
|
|
1288
|
+
The nomination of {pending_nominee} failed - no one seconded it.
|
|
1289
|
+
Announce this to the town and continue with the next player's turn to nominate.
|
|
1290
|
+
"""
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
async def run_defense_phase(self) -> None:
|
|
1294
|
+
"""Run the defense phase where nominees give speeches."""
|
|
1295
|
+
self.phase = GamePhase.DEFENSE
|
|
1296
|
+
|
|
1297
|
+
await self.step_narrator(
|
|
1298
|
+
payload=f"""
|
|
1299
|
+
=== DEFENSE PHASE ===
|
|
1300
|
+
Nominees: {", ".join(self.nominees)}
|
|
1301
|
+
|
|
1302
|
+
Introduce the defense phase dramatically. Each nominee will give a defense speech.
|
|
1303
|
+
"""
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
for nominee in self.nominees:
|
|
1307
|
+
# Narrator introduces nominee
|
|
1308
|
+
await self.step_narrator(
|
|
1309
|
+
payload=f"""
|
|
1310
|
+
Introduce {nominee} to give their defense.
|
|
1311
|
+
Set a dramatic scene as they step forward to plead their case.
|
|
1312
|
+
"""
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
# Nominee gives defense (broadcast)
|
|
1316
|
+
await self.step_agent(
|
|
1317
|
+
nominee,
|
|
1318
|
+
broadcast=True,
|
|
1319
|
+
payload=f"""
|
|
1320
|
+
=== YOUR DEFENSE ===
|
|
1321
|
+
You have been nominated for execution.
|
|
1322
|
+
Other nominees: {[n for n in self.nominees if n != nominee]}
|
|
1323
|
+
|
|
1324
|
+
Give your defense speech. Convince the town to spare you.
|
|
1325
|
+
Everyone can hear what you say.
|
|
1326
|
+
""",
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
async def run_trial_phase(self) -> None:
|
|
1330
|
+
"""Run the trial phase where players vote on nominees."""
|
|
1331
|
+
self.phase = GamePhase.TRIAL
|
|
1332
|
+
|
|
1333
|
+
await self.step_narrator(
|
|
1334
|
+
payload=f"""
|
|
1335
|
+
=== TRIAL PHASE ===
|
|
1336
|
+
Nominees: {", ".join(self.nominees)}
|
|
1337
|
+
|
|
1338
|
+
Announce that voting will now begin. Each player will vote for which nominee
|
|
1339
|
+
should go to the gallows. The nominee with the most votes will be condemned.
|
|
1340
|
+
"""
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
# Each player votes for a nominee (private to narrator)
|
|
1344
|
+
for voter in self.get_alive_players():
|
|
1345
|
+
await self.step_agent(
|
|
1346
|
+
voter.persona.name,
|
|
1347
|
+
broadcast=False,
|
|
1348
|
+
targets=["Narrator"],
|
|
1349
|
+
payload=f"""
|
|
1350
|
+
[PRIVATE - Only the Narrator can see this]
|
|
1351
|
+
=== TRIAL VOTE ===
|
|
1352
|
+
Vote for which nominee should go to the gallows.
|
|
1353
|
+
Nominees: {", ".join(self.nominees)}
|
|
1354
|
+
|
|
1355
|
+
Your response must end with: "I vote for [nominee_name]"
|
|
1356
|
+
""",
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
# Narrator tallies votes using record_trial_vote
|
|
1360
|
+
self.trial_tie_nominees = []
|
|
1361
|
+
await self.step_narrator(
|
|
1362
|
+
payload=f"""
|
|
1363
|
+
Use record_trial_vote to tally the votes. Pass a dictionary mapping each voter's name
|
|
1364
|
+
to the nominee they voted for.
|
|
1365
|
+
|
|
1366
|
+
Example: record_trial_vote({{"Alice": "Bob", "Charlie": "Bob", "David": "Eve"}})
|
|
1367
|
+
|
|
1368
|
+
Nominees: {", ".join(self.nominees)}
|
|
1369
|
+
|
|
1370
|
+
The tool will automatically determine the winner or indicate if a revote is needed.
|
|
1371
|
+
"""
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
# Handle tie revotes if needed
|
|
1375
|
+
while self.trial_tie_nominees and not self.condemned:
|
|
1376
|
+
tied = self.trial_tie_nominees
|
|
1377
|
+
self.trial_tie_nominees = []
|
|
1378
|
+
|
|
1379
|
+
for voter in self.get_alive_players():
|
|
1380
|
+
await self.step_agent(
|
|
1381
|
+
voter.persona.name,
|
|
1382
|
+
broadcast=False,
|
|
1383
|
+
targets=["Narrator"],
|
|
1384
|
+
payload=f"""
|
|
1385
|
+
[PRIVATE - Only the Narrator can see this]
|
|
1386
|
+
=== TRIAL REVOTE ===
|
|
1387
|
+
There was a tie. Vote again, choosing ONLY from: {", ".join(tied)}
|
|
1388
|
+
|
|
1389
|
+
Your response must end with: "I vote for [nominee_name]"
|
|
1390
|
+
""",
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
await self.step_narrator(
|
|
1394
|
+
payload=f"""
|
|
1395
|
+
Use record_trial_vote to tally the revote. Only votes for {", ".join(tied)} are valid.
|
|
1396
|
+
"""
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
async def run_gallows_phase(self) -> None:
|
|
1400
|
+
"""Run the gallows phase: final speech and execution vote."""
|
|
1401
|
+
self.phase = GamePhase.GALLOWS
|
|
1402
|
+
|
|
1403
|
+
if not self.condemned:
|
|
1404
|
+
return
|
|
1405
|
+
|
|
1406
|
+
condemned_role = self.get_player_role(self.condemned)
|
|
1407
|
+
|
|
1408
|
+
# Narrator narrates walk to gallows
|
|
1409
|
+
await self.step_narrator(
|
|
1410
|
+
payload=f"""
|
|
1411
|
+
=== GALLOWS PHASE ===
|
|
1412
|
+
{self.condemned} has been condemned.
|
|
1413
|
+
|
|
1414
|
+
Narrate their walk to the gallows dramatically. Set a somber, tense atmosphere.
|
|
1415
|
+
Then allow them to give their final words.
|
|
1416
|
+
"""
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
# Condemned gives final speech (broadcast)
|
|
1420
|
+
await self.step_agent(
|
|
1421
|
+
self.condemned,
|
|
1422
|
+
broadcast=True,
|
|
1423
|
+
payload="""
|
|
1424
|
+
=== FINAL WORDS ===
|
|
1425
|
+
You have been condemned to the gallows.
|
|
1426
|
+
This may be your last chance to speak.
|
|
1427
|
+
|
|
1428
|
+
Give your final speech. You might:
|
|
1429
|
+
- Proclaim your innocence
|
|
1430
|
+
- Reveal information
|
|
1431
|
+
- Accuse others
|
|
1432
|
+
- Accept your fate
|
|
1433
|
+
|
|
1434
|
+
Everyone can hear your final words.
|
|
1435
|
+
""",
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
# Execution vote - everyone except condemned
|
|
1439
|
+
await self.step_narrator(
|
|
1440
|
+
payload=f"""
|
|
1441
|
+
Now each player (except {self.condemned}) will vote to execute or spare.
|
|
1442
|
+
Ask each player for their vote.
|
|
1443
|
+
"""
|
|
1444
|
+
)
|
|
1445
|
+
|
|
1446
|
+
voters = [
|
|
1447
|
+
p for p in self.get_alive_players() if p.persona.name != self.condemned
|
|
1448
|
+
]
|
|
1449
|
+
for voter in voters:
|
|
1450
|
+
await self.step_agent(
|
|
1451
|
+
voter.persona.name,
|
|
1452
|
+
broadcast=False,
|
|
1453
|
+
targets=["Narrator"],
|
|
1454
|
+
payload=f"""
|
|
1455
|
+
[PRIVATE - Only the Narrator can see this]
|
|
1456
|
+
=== EXECUTION VOTE ===
|
|
1457
|
+
Vote to execute or spare {self.condemned}.
|
|
1458
|
+
|
|
1459
|
+
Your response must end with your vote: "execute" or "spare"
|
|
1460
|
+
""",
|
|
1461
|
+
)
|
|
1462
|
+
|
|
1463
|
+
# Narrator records vote and narrates outcome
|
|
1464
|
+
await self.step_narrator(
|
|
1465
|
+
payload=f"""
|
|
1466
|
+
Record execution votes using record_vote(execute_names, spare_names).
|
|
1467
|
+
Majority decides the outcome.
|
|
1468
|
+
|
|
1469
|
+
If executed:
|
|
1470
|
+
- Narrate the execution dramatically
|
|
1471
|
+
- Reveal {self.condemned}'s role: {condemned_role}
|
|
1472
|
+
- {"NOTE: This is the JESTER! They win if executed!" if condemned_role == "Jester" else ""}
|
|
1473
|
+
|
|
1474
|
+
If spared:
|
|
1475
|
+
- Narrate their release
|
|
1476
|
+
- Do NOT reveal their role
|
|
1477
|
+
|
|
1478
|
+
Don't narrate the start of the next night phase, or if any win condition is met. You will intro the next phase (or narrate the end of the game) in your next message, not this one.
|
|
1479
|
+
"""
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
# Check if execution happened (majority voted execute)
|
|
1483
|
+
execute_count = len(self.current_votes_for)
|
|
1484
|
+
spare_count = len(self.current_votes_against)
|
|
1485
|
+
|
|
1486
|
+
if execute_count > spare_count:
|
|
1487
|
+
# Execution happens
|
|
1488
|
+
if condemned_role == "Jester":
|
|
1489
|
+
self.jester_executed = self.condemned
|
|
1490
|
+
self.kill_player(self.condemned)
|
|
1491
|
+
|
|
1492
|
+
# ==================== Main Game Loop ====================
|
|
1493
|
+
|
|
1494
|
+
async def run(self) -> WinCondition:
|
|
1495
|
+
"""Run the complete game loop."""
|
|
1496
|
+
await self.start_game()
|
|
1497
|
+
await asyncio.sleep(2)
|
|
1498
|
+
|
|
1499
|
+
while self.check_win_condition() == WinCondition.NONE:
|
|
1500
|
+
self.day_number += 1
|
|
1501
|
+
|
|
1502
|
+
await self.run_night_phase()
|
|
1503
|
+
|
|
1504
|
+
if self.check_win_condition() != WinCondition.NONE:
|
|
1505
|
+
break
|
|
1506
|
+
|
|
1507
|
+
await self.run_day_phase()
|
|
1508
|
+
|
|
1509
|
+
await self.announce_winner()
|
|
1510
|
+
return self.winner
|
|
1511
|
+
|
|
1512
|
+
async def announce_winner(self) -> None:
|
|
1513
|
+
"""Announce the game winner."""
|
|
1514
|
+
self.phase = GamePhase.GAME_OVER
|
|
1515
|
+
|
|
1516
|
+
winner_text = ""
|
|
1517
|
+
if self.winner == WinCondition.TOWN_WINS:
|
|
1518
|
+
winner_text = "The TOWN has won! All Mafia members have been eliminated."
|
|
1519
|
+
elif self.winner == WinCondition.MAFIA_WINS:
|
|
1520
|
+
winner_text = "The MAFIA has won! They have achieved parity with the town."
|
|
1521
|
+
elif self.winner == WinCondition.JESTER_WINS:
|
|
1522
|
+
winner_text = f"The JESTER ({self.jester_executed}) has won! They were executed by the town."
|
|
1523
|
+
|
|
1524
|
+
await self.step_narrator(
|
|
1525
|
+
payload=f"""
|
|
1526
|
+
=== GAME OVER ===
|
|
1527
|
+
{winner_text}
|
|
1528
|
+
|
|
1529
|
+
Final role reveals:
|
|
1530
|
+
{self.get_role_assignments_str()}
|
|
1531
|
+
|
|
1532
|
+
Survivors: {", ".join(self.get_alive_names()) if self.get_alive_names() else "None"}
|
|
1533
|
+
Deaths: {", ".join([p.persona.name for p in self.get_dead_players()])}
|
|
1534
|
+
|
|
1535
|
+
Narrate an epic conclusion to the game. Reveal all roles and provide closure to the story.
|
|
1536
|
+
"""
|
|
1537
|
+
)
|