Research · Strategy · season 1 · shoal/werewolf

Werewolf

Play liveChoose your opponent
Join
9cd2307
235 lines · 12.5 KB Raw Permalink History
1"""Real pinned-engine and room regressions. Run with the game's Python.
2
3 .venvs/werewolf/bin/python games/werewolf/referee/test_rules.py
4No running Shoal service, account, network, or persisted game data is used.
5"""
6import contextlib
7import json
8import os
9from pathlib import Path
10import random
11import subprocess
12import sys
13import tempfile
14import threading
15import unittest
16
17HERE = Path(__file__).resolve().parent
18sys.path.insert(0, str(HERE))
19with contextlib.redirect_stdout(sys.stderr):
20 from kaggle_environments import make
21from play import ROLES, configuration, transcript
22from rule_agent import RuleAgent, public_config
23from kaggle_seat import seat
24
25
26def record(name, data=None, day=1, phase='Day', source='MODERATOR', stamp=None):
27 return {'event_name': name, 'day': day, 'phase': phase, 'source': source,
28 'data': data, 'created_at': stamp or f'{day}-{name}-{source}', 'description': '',
29 'detailed_phase': 'DAY_CHAT_AWAIT'}
30
31
32def observation(role='Villager', me=0, phase='DAY_VOTING_AWAIT', entries=None, alive=None, revealed=None, day=1):
33 ids = [f'Player {i + 1}' for i in range(7)]
34 return {'raw_observation': {'player_id': ids[me], 'role': role,
35 'team': 'Werewolves' if role == 'Werewolf' else 'Villagers', 'is_alive': True,
36 'all_player_ids': ids, 'alive_players': alive or ids, 'day': day,
37 'game_state_phase': 'Night' if phase.startswith('NIGHT') else 'Day',
38 'detailed_phase': phase, 'revealed_players': revealed or {},
39 'new_player_event_views': entries or []}}
40
41
42class Rules(unittest.TestCase):
43 def test_config_hides_assignment_and_seed(self):
44 conf = configuration(123, rng=random.Random(10))
45 conf.update({'llms': [{'api_key': 'not-a-real-key'}], 'actTimeout': 3})
46 safe = public_config(conf)
47 self.assertEqual(safe, {'actTimeout': 3})
48 self.assertNotIn('agents', safe)
49 self.assertNotIn('seed', safe)
50
51 def test_balanced_roles_and_private_deals(self):
52 for mine, role in enumerate(ROLES):
53 a = configuration(3, mine, rng=random.Random(10 + mine))
54 b = configuration(3, mine, rng=random.Random(20 + mine))
55 self.assertEqual(a['agents'][mine]['role'], role)
56 self.assertEqual(sorted(p['role'] for p in a['agents']), sorted(ROLES))
57 self.assertNotEqual(a['agents'], b['agents'])
58
59 def test_seer_remembers_and_does_not_repeat(self):
60 p = RuleAgent()
61 result = record('inspect_result', {'actor_id': 'Player 1', 'target_id': 'Player 4', 'role': 'Werewolf'}, phase='Night')
62 vote = p(observation('Seer', entries=[result]))
63 self.assertEqual(vote['kwargs']['target_id'], 'Player 4')
64 req = record('inspect_request', {'valid_candidates': ['Player 4', 'Player 5']}, day=2, phase='Night')
65 action = p(observation('Seer', phase='NIGHT_AWAIT_ACTIONS', entries=[req], day=2))
66 self.assertEqual(action['kwargs']['target_id'], 'Player 5')
67 new_start = record('game_start', {'player_id': 'Player 1', 'role': 'Seer'}, day=0, stamp='second-game')
68 p.observe(observation('Seer', entries=[new_start], day=0))
69 self.assertEqual(p.inspections, {})
70
71 def test_doctor_trusts_a_consistent_claim_and_legal_list(self):
72 claim = record('discussion', {'actor_id': 'Player 3', 'message': '我是预言家。第1夜查验:4号=好人。'}, source='Player 3')
73 req = record('heal_request', {'valid_candidates': ['Player 2', 'Player 3', 'Player 4']}, day=1, phase='Night')
74 p = RuleAgent()
75 action = p(observation('Doctor', phase='NIGHT_AWAIT_ACTIONS', entries=[claim, req]))
76 self.assertEqual(action['kwargs']['target_id'], 'Player 3')
77 req['data']['valid_candidates'] = ['Player 2', 'Player 4']
78 action = p(observation('Doctor', phase='NIGHT_AWAIT_ACTIONS', entries=[req]))
79 self.assertIn(action['kwargs']['target_id'], ['Player 2', 'Player 4'])
80
81 def test_claims_are_not_facts(self):
82 p = RuleAgent()
83 speech = record('discussion', {'actor_id': 'Player 2', 'message': '我是预言家。查验:3号=狼人。'}, source='Player 2')
84 p.observe(observation(entries=[speech], revealed={'Player 3': 'Villager'}))
85 self.assertEqual(p.facts['Player 3'], 'Villager')
86 self.assertEqual(p.claimed('Player 2', 'Seer'), 0)
87 self.assertEqual(p.belief()['Player 3'], 0)
88 p.read_claim('Player 4', '我不是预言家。查验:3号=狼人。')
89 self.assertNotIn('Player 4', p.claims)
90
91 def test_wolves_coordinate_and_never_vote_their_partner(self):
92 req = record('vote_request', {'valid_targets': ['Player 3', 'Player 4'],
93 'alive_werewolve_player_ids': ['Player 1', 'Player 2']}, phase='Night')
94 vote = record('vote_action', {'actor_id': 'Player 2', 'target_id': 'Player 4'}, phase='Night', source='Player 2')
95 p = RuleAgent()
96 action = p(observation('Werewolf', phase='NIGHT_AWAIT_ACTIONS', entries=[req, vote]))
97 self.assertEqual(action['kwargs']['target_id'], 'Player 4')
98 action = p(observation('Werewolf'))
99 self.assertNotIn(action['kwargs']['target_id'], ['Player 1', 'Player 2'])
100
101 def test_last_ballot_uses_random_tie_outcome(self):
102 p = RuleAgent()
103 p.observe(observation('Seer', alive=['Player 1', 'Player 2', 'Player 3']))
104 p.facts.update({'Player 2': 'Werewolf', 'Player 3': 'Villager'})
105 p.votes = {1: {'Player 2': 'Player 3', 'Player 3': 'Player 2'}}
106 self.assertEqual(p.ballot(p.alive, p.belief()), 'Player 2')
107
108 def test_pinned_engine_doctor_can_protect_same_person_again(self):
109 env = make('werewolf', configuration={'seed': 3})
110 env.reset(7)
111 saves, offered = [], []
112 for _ in range(80):
113 actions = [None] * 7
114 for i, state in enumerate(env.state):
115 if state.status != 'ACTIVE':
116 continue
117 raw = state.observation.raw_observation
118 kwargs = {'actor_id': raw.player_id, 'day': raw.day, 'phase': raw.game_state_phase}
119 if raw.detailed_phase == 'DAY_CHAT_AWAIT':
120 kind, kwargs['message'] = 'ChatAction', 'test'
121 elif raw.detailed_phase == 'DAY_VOTING_AWAIT':
122 kind = 'VoteAction'
123 kwargs['target_id'] = 'Player 7' if 'Player 7' in raw.alive_players else 'Player 6'
124 else:
125 kind = {'Werewolf': 'VoteAction', 'Doctor': 'HealAction', 'Seer': 'InspectAction'}[raw.role]
126 kwargs['target_id'] = 'Player 1' if raw.role == 'Seer' else 'Player 5'
127 if raw.role == 'Doctor':
128 req = next(e for e in raw.new_player_event_views if e.event_name == 'heal_request')
129 offered.append(req.data['valid_candidates'])
130 actions[i] = {'action_type': kind, 'kwargs': kwargs}
131 env.step(actions)
132 for rec in env.info['MODERATOR_OBSERVATION'][-1]:
133 e = json.loads(rec['json_str'])
134 if e['event_name'] == 'heal_result':
135 saves.append(e['data']['saved_player_id'])
136 if len(saves) == 2:
137 break
138 self.assertEqual(saves, ['Player 5', 'Player 5'])
139 self.assertTrue(all('Player 4' not in choices and 'Player 5' in choices for choices in offered))
140
141 def test_real_engine_all_abstain_randomly_eliminates(self):
142 env = make('werewolf', configuration={'seed': 24})
143 env.reset(7)
144 while not env.done:
145 actions = [None] * 7
146 for i, state in enumerate(env.state):
147 if state.status == 'ACTIVE':
148 r = state.observation.raw_observation
149 actions[i] = {'action_type': 'NoOpAction', 'kwargs': {'actor_id': r.player_id, 'day': r.day, 'phase': r.game_state_phase}}
150 env.step(actions)
151 story = transcript(env.toJSON(), ['abstain'] * 7)
152 tallies = [e for e in story['events'] if e['kind'] == 'tally']
153 self.assertTrue(tallies)
154 self.assertTrue(all(e['all_abstained'] and e['exiled'] is not None for e in tallies))
155
156 def test_20_real_games_end_without_errors(self):
157 roles = set()
158 for seed in range(20):
159 random.seed(seed)
160 env = make('werewolf', configuration=configuration(seed, rng=random.Random(1000 + seed)))
161 env.run([RuleAgent() for _ in range(7)])
162 self.assertTrue(env.done)
163 self.assertIn(env.info['GAME_END']['winner_team'], ['Werewolves', 'Villagers'])
164 self.assertFalse(env.info['GAME_END']['terminated_with_agent_error'])
165 roles.update(str(p.role.name) for p in env.game_state.players)
166 for step in env.info['MODERATOR_OBSERVATION']:
167 for rec in step:
168 e = json.loads(rec['json_str'])
169 self.assertNotEqual(e['event_name'], 'error', e)
170 self.assertEqual(roles, set(ROLES))
171
172 def test_actual_child_process_gets_redacted_config(self):
173 with tempfile.TemporaryDirectory(prefix='werewolf-child-') as tmp:
174 Path(tmp, 'main.py').write_text('def agent(obs, config):\n return config\n')
175 child = seat(tmp, limit=3)
176 try:
177 answer = child({}, {'seed': 12, 'agents': [{'role': 'Werewolf'}], 'actTimeout': 3})
178 self.assertEqual(answer, {'actTimeout': 3})
179 finally:
180 child.child.close()
181 child.child.p.wait(timeout=5)
182 child.child.p.stdin.close()
183 child.child.p.stdout.close()
184
185 def test_live_room_requests_actions_privacy_and_replay(self):
186 with tempfile.TemporaryDirectory(prefix='werewolf-room-') as tmp:
187 conf = {'seed': 7, 'limit': 3, 'seats': {str(i): {'kind': 'human', 'name': f'test-{i}'} for i in range(7)}}
188 env = {**os.environ, 'SS_ROOM': json.dumps(conf), 'SS_INPUT': str(HERE.parent)}
189 with open(Path(tmp, 'stderr.log'), 'w') as errors:
190 proc = subprocess.Popen([sys.executable, '-s', str(HERE / 'room.py')], cwd=tmp, env=env,
191 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, text=True)
192 deadline = threading.Timer(20, proc.kill)
193 deadline.start()
194 policies = [RuleAgent() for _ in range(7)]
195 roles, requests, ended = {}, set(), False
196 try:
197 for line in proc.stdout:
198 if not line.startswith('@shoal '):
199 continue
200 msg = json.loads(line[7:])
201 e = msg.get('event')
202 if e:
203 self.assertNotIn(e['kind'], ['inspect', 'heal', 'wolf_vote', 'wolf_pick', 'saved', 'role'])
204 self.assertNotIn('why', e)
205 if msg.get('obs', {}).get('kind') == 'role':
206 roles[str(msg['to'])] = msg['obs']['role']
207 if 'legal' in msg:
208 raw = msg['obs']['raw']
209 requests.add(msg['obs']['request'])
210 self.assertEqual(raw['role'], roles[str(msg['to'])])
211 if msg['obs']['request'] == 'vote':
212 self.assertIn({'type': 'vote', 'target': str(msg['to'])}, msg['legal'])
213 if msg['obs']['request'] in ('vote', 'kill'):
214 self.assertIn({'type': msg['obs']['request'], 'target': None}, msg['legal'])
215 action = policies[int(msg['to'])]({'raw_observation': raw})
216 proc.stdin.write(json.dumps({'act': {'seat': msg['to'], 'version': msg['version'], 'action': action}}) + '\n')
217 proc.stdin.flush()
218 if 'end' in msg:
219 ended = True
220 self.assertEqual(proc.wait(timeout=5), 0)
221 self.assertTrue(ended)
222 self.assertEqual(requests, {'chat', 'vote', 'kill', 'heal', 'inspect'})
223 self.assertTrue(Path(tmp, 'replays/matches.json').is_file())
224 self.assertNotIn('out of time', Path(tmp, 'stderr.log').read_text())
225 finally:
226 deadline.cancel()
227 if proc.poll() is None:
228 proc.kill()
229 proc.wait()
230 proc.stdin.close()
231 proc.stdout.close()
232
233
234if __name__ == '__main__':
235 unittest.main(verbosity=2)