| 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 |
| 4 | No running Shoal service, account, network, or persisted game data is used. |
| 5 | """ |
| 6 | import contextlib |
| 7 | import json |
| 8 | import os |
| 9 | from pathlib import Path |
| 10 | import random |
| 11 | import subprocess |
| 12 | import sys |
| 13 | import tempfile |
| 14 | import threading |
| 15 | import unittest |
| 16 | |
| 17 | HERE = Path(__file__).resolve().parent |
| 18 | sys.path.insert(0, str(HERE)) |
| 19 | with contextlib.redirect_stdout(sys.stderr): |
| 20 | from kaggle_environments import make |
| 21 | from play import ROLES, configuration, transcript |
| 22 | from rule_agent import RuleAgent, public_config |
| 23 | from kaggle_seat import seat |
| 24 | |
| 25 | |
| 26 | def 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 | |
| 32 | def 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 | |
| 42 | class 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 | |
| 234 | if __name__ == '__main__': |
| 235 | unittest.main(verbosity=2) |