| 1 | """Offline evidence-based player for the pinned seven-player Kaggle rules. |
| 2 | |
| 3 | Only role-filtered observations enter this policy. It keeps a journal, distinguishes |
| 4 | private facts from public claims, enumerates compatible wolf teams, and uses the |
| 5 | sequential ballot. This is a heuristic opponent, not a natural-language model. |
| 6 | """ |
| 7 | from collections import Counter |
| 8 | import hashlib |
| 9 | import itertools |
| 10 | import json |
| 11 | import math |
| 12 | import re |
| 13 | |
| 14 | |
| 15 | def public_config(config): |
| 16 | """Never send the role assignment, seed, harness settings or credentials to a seat.""" |
| 17 | keys = ('episodeSteps', 'actTimeout', 'runTimeout', 'discussion_protocol', |
| 18 | 'day_voting_protocol', 'werewolf_night_vote_protocol', |
| 19 | 'night_elimination_reveal_level', 'day_exile_reveal_level') |
| 20 | return {k: config[k] for k in keys if k in config} |
| 21 | |
| 22 | |
| 23 | class RuleAgent: |
| 24 | def __init__(self): |
| 25 | self.reset() |
| 26 | |
| 27 | def reset(self): |
| 28 | self.start = None |
| 29 | self.seen = set() |
| 30 | self.facts, self.inspections, self.claims = {}, {}, {} |
| 31 | self.votes, self.night_votes, self.speeches = {}, {}, {} |
| 32 | self.saved, self.wolves = set(), set() |
| 33 | self.counts = {'Werewolf': 2, 'Seer': 1, 'Doctor': 1, 'Villager': 3} |
| 34 | self.heal = None |
| 35 | |
| 36 | def observe(self, obs): |
| 37 | raw = obs.get('raw_observation', obs) |
| 38 | entries = raw.get('new_player_event_views', []) |
| 39 | start = next((e.get('created_at') for e in entries if e['event_name'] == 'game_start'), None) |
| 40 | if start is not None and start != self.start: |
| 41 | self.reset() |
| 42 | self.start = start |
| 43 | self.raw = raw |
| 44 | self.me, self.role = raw['player_id'], raw['role'] |
| 45 | self.ids, self.alive = raw['all_player_ids'], raw['alive_players'] |
| 46 | self.day = raw['day'] |
| 47 | self.facts[self.me] = self.role |
| 48 | self.facts.update({p: r for p, r in raw.get('revealed_players', {}).items() if r}) |
| 49 | for e in entries: |
| 50 | key = json.dumps(e, sort_keys=True, default=str) |
| 51 | if key in self.seen: |
| 52 | continue |
| 53 | self.seen.add(key) |
| 54 | d, name = e.get('data') or {}, e['event_name'] |
| 55 | actor = d.get('actor_id', e.get('source')) |
| 56 | if d.get('role_counts'): |
| 57 | self.counts = d['role_counts'] |
| 58 | if name == 'vote_request' and d.get('alive_werewolve_player_ids'): |
| 59 | self.wolves.update(d['alive_werewolve_player_ids']) |
| 60 | self.facts.update({p: 'Werewolf' for p in self.wolves}) |
| 61 | if name == 'inspect_result' and actor == self.me: |
| 62 | r = d.get('role') or d.get('team') |
| 63 | self.inspections[d['target_id']] = (r, e['day']) |
| 64 | self.facts[d['target_id']] = r |
| 65 | if name == 'heal_result': |
| 66 | self.saved.add(d['saved_player_id']) |
| 67 | self.facts[d['saved_player_id']] = 'Villagers' |
| 68 | if name == 'heal_action' and actor == self.me: |
| 69 | self.heal = d.get('target_id') |
| 70 | if name == 'vote_action' and actor in self.ids: |
| 71 | ledger = self.votes if e['phase'] == 'Day' else self.night_votes |
| 72 | ledger.setdefault(e['day'], {})[actor] = d.get('target_id') |
| 73 | if name == 'discussion' and actor in self.ids: |
| 74 | text = str(d.get('message', ''))[:600] |
| 75 | self.speeches[actor] = text |
| 76 | self.read_claim(actor, text) |
| 77 | return raw |
| 78 | |
| 79 | def read_claim(self, actor, text): |
| 80 | |
| 81 | |
| 82 | role = None |
| 83 | if re.search(r'(?:我是|我才是|我真的是)\s*预言家|\bI\s+am\s+(?:the\s+|a\s+)?seer\b', text, re.I): |
| 84 | role = 'Seer' |
| 85 | elif re.search(r'(?:我是|我才是)\s*医生|\bI\s+am\s+(?:the\s+|a\s+)?doctor\b', text, re.I): |
| 86 | role = 'Doctor' |
| 87 | if not role: |
| 88 | return |
| 89 | claim = self.claims.setdefault(actor, {'role': role, 'reports': {}}) |
| 90 | claim['role'] = role |
| 91 | if role != 'Seer' or not re.search(r'查验|查了|验了|\binspect(?:ed)?\b|\bchecked\b', text, re.I): |
| 92 | return |
| 93 | for i, pid in enumerate(self.ids): |
| 94 | label = rf'(?:{re.escape(pid)}|(?<!\d){i + 1}\s*号)' |
| 95 | pattern = label + r'\s*(?:=|:|:|是|为|is\s+(?:a\s+)?)\s*(狼人|好人|平民|医生|预言家|werewolf|villager|doctor|seer)' |
| 96 | for hit in re.finditer(pattern, text, re.I): |
| 97 | prefix = text[max(0, hit.start() - 3):hit.start()] |
| 98 | tail = text[hit.end():hit.end() + 1] |
| 99 | if '不' in prefix or tail in ('?', '?'): |
| 100 | continue |
| 101 | word = hit.group(1).lower() |
| 102 | claim['reports'][pid] = 'Werewolf' if word in ('狼人', 'werewolf') else 'Villagers' |
| 103 | |
| 104 | @staticmethod |
| 105 | def wolf(role): |
| 106 | return role in ('Werewolf', 'Werewolves') |
| 107 | |
| 108 | def tie(self, pid): |
| 109 | |
| 110 | |
| 111 | key = f'{self.me}|{self.day}|{pid}'.encode() |
| 112 | return int.from_bytes(hashlib.sha256(key).digest()[:4], 'big') / 2**32 |
| 113 | |
| 114 | def belief(self): |
| 115 | """Small exact wolf-team enumeration with explicitly heuristic weights.""" |
| 116 | assignments = [] |
| 117 | total_wolves = int(self.counts.get('Werewolf', 2)) |
| 118 | for team in itertools.combinations(self.ids, total_wolves): |
| 119 | ws = set(team) |
| 120 | if any((p in ws) != self.wolf(r) for p, r in self.facts.items() if p in self.ids): |
| 121 | continue |
| 122 | logw = 0.0 |
| 123 | for actor, c in self.claims.items(): |
| 124 | reports = c['reports'] |
| 125 | wrong = sum((p in ws) != self.wolf(r) for p, r in reports.items()) |
| 126 | |
| 127 | |
| 128 | if c['role'] == 'Seer': |
| 129 | other_seer = any(p != actor and r == 'Seer' for p, r in self.facts.items()) |
| 130 | if actor not in ws: |
| 131 | logw += -3.5 * wrong + (0.8 if reports and not wrong and not other_seer else 0) |
| 132 | if other_seer: |
| 133 | logw -= 1.8 |
| 134 | elif wrong: |
| 135 | logw += min(1.2, 0.4 * wrong) |
| 136 | proven = self.facts.get(actor) |
| 137 | if proven and not self.wolf(proven) and proven != 'Villagers' and proven != c['role']: |
| 138 | continue |
| 139 | |
| 140 | |
| 141 | for day, ballots in self.votes.items(): |
| 142 | if day >= self.day: |
| 143 | continue |
| 144 | for actor, target in ballots.items(): |
| 145 | if actor not in self.alive or target not in self.facts: |
| 146 | continue |
| 147 | if self.wolf(self.facts[target]): |
| 148 | logw += -0.75 if actor in ws else 0.25 |
| 149 | elif actor in ws: |
| 150 | logw += 0.12 |
| 151 | assignments.append((ws, math.exp(logw))) |
| 152 | z = sum(w for _, w in assignments) |
| 153 | if not z: |
| 154 | return {p: float(self.wolf(self.facts[p])) if p in self.facts else 0.5 for p in self.ids} |
| 155 | return {p: sum(w for ws, w in assignments if p in ws) / z for p in self.ids} |
| 156 | |
| 157 | def claimed(self, pid, role): |
| 158 | c = self.claims.get(pid) |
| 159 | if not c or c['role'] != role: |
| 160 | return 0.0 |
| 161 | contradictions = sum(p in self.facts and self.wolf(self.facts[p]) != self.wolf(r) |
| 162 | for p, r in c['reports'].items()) |
| 163 | if contradictions: |
| 164 | return 0.0 |
| 165 | |
| 166 | competitors = sum(c2['role'] == role for c2 in self.claims.values()) |
| 167 | return 1.0 / max(1, competitors) |
| 168 | |
| 169 | def ballot(self, targets, belief): |
| 170 | ballots = self.votes.get(self.day, {}) |
| 171 | counts = Counter(t for t in ballots.values() if t in self.alive) |
| 172 | wolf = self.role == 'Werewolf' |
| 173 | quality = {p: (0.0 if p in self.wolves else 1.0) if wolf else belief[p] for p in self.alive} |
| 174 | options = [p for p in targets if p != self.me] |
| 175 | if wolf: |
| 176 | options = [p for p in options if p not in self.wolves] or options |
| 177 | if not options: |
| 178 | return '-1' |
| 179 | remaining = len(self.alive) - len(ballots) - 1 |
| 180 | |
| 181 | def score(p): |
| 182 | after = counts.copy() |
| 183 | after[p] += 1 |
| 184 | top = max(after.values()) |
| 185 | leaders = [x for x, v in after.items() if v == top] |
| 186 | outcome = sum(quality[x] for x in leaders) / len(leaders) |
| 187 | if remaining <= 0: |
| 188 | return outcome, quality[p], self.tie(p) |
| 189 | |
| 190 | support = counts[p] / max(1, len(self.alive)) |
| 191 | value = quality[p] + 0.32 * support + 0.18 * outcome |
| 192 | if wolf: |
| 193 | value += 0.12 * self.claimed(p, 'Seer') |
| 194 | return value, quality[p], self.tie(p) |
| 195 | return max(options, key=score) |
| 196 | |
| 197 | def reason(self, target, belief): |
| 198 | if target in self.inspections: |
| 199 | return f'自己的查验已确认{self.label(target)}是{"狼人" if self.wolf(self.inspections[target][0]) else "好人"}' |
| 200 | if belief.get(target, 0) > .85: |
| 201 | claim = self.claims.get(target, {}) |
| 202 | if any(p in self.facts and self.wolf(self.facts[p]) != self.wolf(r) |
| 203 | for p, r in claim.get('reports', {}).items()): |
| 204 | return '其查验声明与已确认的身份冲突,结合剩余狼人数判断' |
| 205 | return '根据已确认的身份、公开票型和剩余狼人数判断,仍需核对发言' |
| 206 | if any(c['reports'].get(target) == 'Werewolf' for c in self.claims.values()): |
| 207 | return '有人公开报了查杀;仍需结合其后续翻牌验证,不能当作铁证' |
| 208 | return '结合公开发言、历次票型与剩余人数;目前只是怀疑,没有硬证据' |
| 209 | |
| 210 | def label(self, pid): |
| 211 | return f'{self.ids.index(pid) + 1}号' if pid in self.ids else str(pid) |
| 212 | |
| 213 | def speech(self, belief): |
| 214 | target = self.ballot(self.alive, belief) |
| 215 | if self.role == 'Seer' and self.inspections: |
| 216 | reports = ';'.join(f'第{d + 1}夜查验:{self.label(p)}={"狼人" if self.wolf(r) else "好人"}' |
| 217 | for p, (r, d) in self.inspections.items()) |
| 218 | return f'我是预言家。{reports}。今天建议投{self.label(target)}。请核对我的查验记录,医生可考虑守护我。' |
| 219 | if self.role == 'Doctor' and self.saved and self.day > 1: |
| 220 | saved = '、'.join(self.label(p) for p in sorted(self.saved)) |
| 221 | return f'我是医生。此前成功救下{saved},他们不是狼人。今天建议投{self.label(target)};{self.reason(target, belief)}。' |
| 222 | if self.role == 'Werewolf': |
| 223 | |
| 224 | |
| 225 | accusers = [p for p, c in self.claims.items() if p in self.alive and p not in self.wolves |
| 226 | and any(t in self.wolves and r == 'Werewolf' for t, r in c['reports'].items())] |
| 227 | if accusers and len(self.alive) > 3: |
| 228 | target = accusers[0] |
| 229 | return f'我是预言家。第{self.day}夜查验:{self.label(target)}=狼人。{self.label(target)}的查验说法不可信,今天请投{self.label(target)}。' |
| 230 | return f'我倾向投{self.label(target)}。先看发言和票型,不能只凭自称预言家就认定好人;请大家核对查验是否与翻牌一致。' |
| 231 | return f'我倾向投{self.label(target)}:{self.reason(target, belief)}。预言家请给出查验对象和结果,大家投票前再核对。' |
| 232 | |
| 233 | def __call__(self, obs, config=None): |
| 234 | raw = self.observe(obs) |
| 235 | base = {'day': raw['day'], 'phase': raw['game_state_phase'], 'actor_id': self.me} |
| 236 | |
| 237 | def action(kind, why, **kw): |
| 238 | return {'action_type': kind, 'kwargs': {**base, 'reasoning': why, **kw}} |
| 239 | |
| 240 | def candidates(name, key): |
| 241 | for e in reversed(raw.get('new_player_event_views', [])): |
| 242 | if e['event_name'] == name and key in (e.get('data') or {}): |
| 243 | return e['data'][key] |
| 244 | return [] |
| 245 | |
| 246 | phase = raw['detailed_phase'] |
| 247 | belief = self.belief() |
| 248 | if phase == 'DAY_CHAT_AWAIT': |
| 249 | return action('ChatAction', '用本座位可见的查验、救援与公开证据发言;声明不是系统认证。', message=self.speech(belief)) |
| 250 | if phase == 'DAY_BIDDING_AWAIT': |
| 251 | return action('BidAction', '有查验信息时优先发言。', amount=4 if self.inspections else 1) |
| 252 | if phase == 'DAY_VOTING_AWAIT': |
| 253 | target = self.ballot(self.alive, belief) |
| 254 | return action('VoteAction', self.reason(target, belief) if self.role != 'Werewolf' else '结合当前票数集中投给非狼玩家,保护狼队友。', target_id=target) |
| 255 | if phase == 'NIGHT_AWAIT_ACTIONS': |
| 256 | if self.role == 'Seer': |
| 257 | targets = candidates('inspect_request', 'valid_candidates') |
| 258 | fresh = [p for p in targets if p not in self.facts] or targets |
| 259 | if fresh: |
| 260 | target = max(fresh, key=lambda p: (self.claimed(p, 'Seer') + 4 * belief[p] * (1 - belief[p]), self.tie(p))) |
| 261 | return action('InspectAction', '优先查验尚无硬证据且能消除争议的玩家,避免重复查验。', target_id=target) |
| 262 | if self.role == 'Doctor': |
| 263 | targets = candidates('heal_request', 'valid_candidates') |
| 264 | if targets: |
| 265 | target = max(targets, key=lambda p: (2.8 * self.claimed(p, 'Seer') + (2 if self.facts.get(p) == 'Seer' else 0) |
| 266 | + (1 - belief[p]) + (0.4 if p in self.saved else 0), -self.ids.index(p))) |
| 267 | return action('HealAction', '在引擎允许的目标中优先保护可信预言家或已确认的好人;此规则允许连续守护。', target_id=target) |
| 268 | if self.role == 'Werewolf': |
| 269 | targets = candidates('vote_request', 'valid_targets') or [p for p in self.alive if p not in self.wolves] |
| 270 | picks = self.night_votes.get(self.day, {}) |
| 271 | if targets: |
| 272 | target = max(targets, key=lambda p: (4 * sum(t == p for a, t in picks.items() if a != self.me) |
| 273 | + 2 * self.claimed(p, 'Doctor') + self.claimed(p, 'Seer'), self.tie(p))) |
| 274 | return action('VoteAction', '参考狼队友已投的夜票,优先协同攻击公开特殊身份;不读取其他座位秘密。', target_id=target) |
| 275 | return action('NoOpAction', '当前没有本身份可执行的动作。') |