+1 −1 AGENTS.md 查看文件
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | - Your bot is a kaggle-environments Werewolf agent: `routes/<your route>/main.py` with `agent(obs, config)`. Start from `routes/main.py`, the starter. It runs on Python 3.13 with kaggle-environments 1.33.0 installed, and no network. |
| 4 | 4 | - Test before you push: `python3 -I referee/play.py check routes/<your route>` plays every seat against the field and prints the score by role. |
| 5 | -- Every push is checked: your bot plays every seat on three seeds and the share of games its side wins is kept. You do not write `metrics.json` yourself. | |
| 5 | +- Every push is checked: version 2 plays three balanced rounds of every seat against each field bot, with private fresh role deals; the share of games its side wins is kept. Your process receives public configuration only, not player roles or referee seeds. Reset memory at each new game_start. You do not write `metrics.json` yourself. | |
| 6 | 6 | - For replays, submit a practice job: `sbatch referee/practice.sbatch`. Its games show up under Matches, on the table. |
| 7 | 7 | - Write what you tried and what to try next in `routes/<your route>/README.md`, ending with `## Next`. |
| 8 | 8 | - To play a game yourself, or watch your bot in one: `python3 kit/roomcli.py --room '<seats>' -- bash referee/room.sbatch` runs a room on your machine (see the README, Play live). |
+26 −7 README.md 查看文件
| @@ -1,6 +1,14 @@ | ||
| 1 | 1 | # Werewolf |
| 2 | 2 | |
| 3 | -Seven players in a village: two werewolves, a seer, a doctor and three villagers. At night the werewolves pick a victim, the doctor protects someone and the seer learns one player's role; by day everyone talks and votes someone out. The villagers win when the werewolves are gone, the werewolves when they match the villagers. This is Google's [kaggle-environments](https://github.com/Kaggle/kaggle-environments) Werewolf, the game Kaggle Game Arena uses to measure how models talk, suspect and deceive, with its own rules and agent interface. | |
| 3 | +Seven players in a village: two werewolves, a seer, a doctor and three villagers. At night the werewolves pick a victim, the doctor protects someone and the seer learns one player's role; by day everyone talks and votes someone out. The villagers win when the werewolves are gone, the werewolves when they match the villagers. This is the seven-player default variant in [kaggle-environments 1.33.0](https://pypi.org/project/kaggle-environments/1.33.0/), using its own engine and agent interface. It is not the current eight-player Game Arena preset. | |
| 4 | + | |
| 5 | +## This table’s rules | |
| 6 | + | |
| 7 | +The pinned engine is authoritative: 2 werewolves, 1 seer, 1 doctor and 3 villagers. The doctor cannot protect themselves, but **can protect the same person on consecutive nights**. The seer learns an exact role. Werewolves vote in sequence, see earlier wolf votes and can only target non-wolves; they do not need unanimous agreement. The highest vote wins and ties are random. | |
| 8 | + | |
| 9 | +Each living player speaks once per day and then votes in sequence. Self-votes and abstentions are legal. Ties are random; **if everyone abstains, the engine still eliminates a random eligible player**, both by day and at night. Eliminations reveal roles. There is no witch, hunter, sheriff election, last-words phase or private wolf chat in this preset. Doctor and seer actions are private; successful protection identifies a non-wolf only to the doctor. | |
| 10 | + | |
| 11 | +The [Game Arena announcement](https://www.kaggle.com/blog/game-arena-werewolf) describes an eight-player preset with different night consensus and consecutive-protection rules. Those must not be silently substituted into this seven-player game. | |
| 4 | 12 | |
| 5 | 13 | ## Your bot |
| 6 | 14 | |
| @@ -8,30 +16,33 @@ Exactly a kaggle-environments Werewolf agent: `main.py` with `def agent(obs, con | ||
| 8 | 16 | |
| 9 | 17 | ## Scoring |
| 10 | 18 | |
| 11 | -On three seeds your bot takes every seat in turn, so it plays every role, and six bots of one kind from the field fill the village. Your score is the share of games your side wins; the Leaderboard shows it by role, the number players of social games compare most. | |
| 19 | +Version 2 plays three rounds of seven games against each field opponent. The candidate takes every seat and covers the role mix (two wolf games, one seer, one doctor, three villager games per round); all other roles are privately shuffled anew for each game. Public gameplay seeds do not determine the hidden deal. Only public timing and rule configuration reaches a bot, never the full player-role map or the deal seed. Keep memory only from your own observations and reset on a new `game_start`. | |
| 20 | + | |
| 21 | +Your score is the share of games your side wins, with separate role scores. Finite samples vary between checks; these results are not Game Arena ratings. Version 1 used fixed role positions and sent the complete role configuration to seat processes. Its numbers, including the old starter baseline, are not comparable and are retained only as historical results. | |
| 12 | 22 | |
| 13 | -## The field, season 1 | |
| 23 | +## The field, season 2 | |
| 14 | 24 | |
| 15 | 25 | | Bot | What it does | |
| 16 | 26 | |---|---| |
| 27 | +| `evidence` | Recommended fixed computer: tracks its own evidence, cross-checks public seer claims against revealed roles, reasons over possible wolf teams, remembers inspections, coordinates wolf votes, protects credible information sources and accounts for sequential ballot ties; no model or network | | |
| 17 | 28 | | `deterministic` | kaggle-environments' built-in deterministic agent: always one fixed thing (the starter is the same) | |
| 18 | 29 | | `random` | kaggle-environments' built-in random agent | |
| 19 | 30 | |
| 20 | 31 | ## Play live |
| 21 | 32 | |
| 22 | -A room is one game played live, with people, agents and bots at the seven seats: seat `0` to `6` is Player 1 to 7 (1号 to 7号 on the table), and the engine deals the roles at random (or from the room's `seed`). A bot seat runs a route's or a field bot's `main.py` in a process of its own, as the checks do. A seat on auto, a player out of time and a bot that fails are played by kaggle-environments' deterministic agent. Everyone sees the public game: phases, the judge's announcements, speeches, day votes, the tally, who is out and their revealed role. Each seat is also told what only it may know: its role card (and, for a werewolf, the other wolf), the wolves' night picks, the seer's results and the doctor's own heals. When the game ends every role is shown, the winning side's seats score 1, and the game is kept as a match. | |
| 33 | +A room is one game played live, with people, agents and bots at the seven seats: seat `0` to `6` is Player 1 to 7 (1号 to 7号 on the table), and the engine deals the roles at random (or from the room's `seed`). A bot seat runs a route's or a field bot's `main.py` in a process of its own, as the checks do. A seat on auto, a player out of time and a bot that fails are played by the `evidence` rule computer, with a separate memory for each seat. Everyone sees the public game: phases, the judge's announcements, speeches, day votes, the tally, who is out and their revealed role. Each seat is also told what only it may know: its role card (and, for a werewolf, the other wolf), the wolves' night picks, the seer's results and the doctor's own heals. When the game ends every role is shown, the winning side's seats score 1, and the game is kept as a match. | |
| 23 | 34 | |
| 24 | 35 | When it is your seat's turn you get your role and team, your wolf teammates, the day and phase, who is alive, what you have been told, the public events so far, your time limit, and Kaggle's own `raw` observation, so a Kaggle agent can play from it as it is. Answer with one of the legal actions: |
| 25 | 36 | |
| 26 | 37 | | Asked | Legal actions | You answer | |
| 27 | 38 | |---|---|---| |
| 28 | 39 | | speak (`chat`) | `{"type": "chat", "message": ""}` | the same with your speech in `message` (at most 600 characters; left empty, you pass) | |
| 29 | -| day vote (`vote`) | `{"type": "vote", "target": "3"}` for each other living seat, and `{"type": "vote", "target": null}` to abstain | one of them | | |
| 30 | -| werewolf's night pick (`kill`) | `{"type": "kill", "target": "4"}` for each living villager | one of them | | |
| 40 | +| day vote (`vote`) | `{"type": "vote", "target": "3"}` for each living seat (self included), and `{"type": "vote", "target": null}` to abstain | one of them | | |
| 41 | +| werewolf's night pick (`kill`) | `{"type": "kill", "target": "4"}` for each living non-wolf, and `{"type": "kill", "target": null}` to abstain | one of them | | |
| 31 | 42 | | doctor (`heal`) | `{"type": "heal", "target": "2"}` for each other living seat | one of them | |
| 32 | 43 | | seer (`inspect`) | `{"type": "inspect", "target": "5"}` for each other living seat | one of them | |
| 33 | 44 | |
| 34 | -Any answer may carry `"reasoning"`, which the replay shows in the god view. An agent can also answer with the Kaggle action its `agent(obs, config)` returns. A speech gets three times the room's time limit (90 seconds by default). | |
| 45 | +Any answer may carry `"reasoning"`, which the replay shows in the god view. An agent can also answer with the Kaggle action its `agent(obs, config)` returns. A speech gets three times the room's time limit (90 seconds by default). An explicitly seeded local room is a reproducible practice game, not a secret-deal competitive room; normal platform rooms choose an undisclosed random seed. | |
| 35 | 46 | |
| 36 | 47 | Try one on your machine, playing seat 0 from the terminal against the starter and the field: |
| 37 | 48 | |
| @@ -41,6 +52,14 @@ SS_INPUT=$PWD python3 kit/roomcli.py --room '{"seats": {"0": {"kind": "human", " | ||
| 41 | 52 | |
| 42 | 53 | Seats the room does not name are played by the stand-in. `--seat 0 --pick random` answers for you; a speech then passes. |
| 43 | 54 | |
| 55 | +The platform offers four entry modes: online multiplayer (wait in the lobby, then explicitly start), play your own connected agent, play a platform route bot, and play the fixed-rule computer. In a seven-seat game the other seats still need participants or explicitly selected computer fill. `evidence` is the default computer; `deterministic` and `random` remain available as simpler opponents. | |
| 56 | + | |
| 57 | +The rule computer understands a bounded set of explicit Chinese/English role and inspection statements, not arbitrary language. It treats them as claims, not system facts. It can bluff as a wolf, and it does not read another seat’s private observations. It is a useful offline opponent, not a claim of expert human-level play. | |
| 58 | + | |
| 59 | +## Verify locally | |
| 60 | + | |
| 61 | +Run `.venvs/werewolf/bin/python games/werewolf/referee/test_rules.py` from the repository root. This runs the real pinned engine, an actual bot child, and an entire live room through its request/answer protocol in temporary directories. The audit and the limited strength comparison are documented in `docs/research/2026-10-23-werewolf-audit.md`. | |
| 62 | + | |
| 44 | 63 | ## The table |
| 45 | 64 | |
| 46 | 65 | `viewer/` is the 狼人杀 table people know from NetEase's app: numbered coin seats down both sides, a moon or sun medallion with 第N夜 / 第N天, one status line ("【4号】发言中…… 47秒"), the judge's cards, the exile vote board with the tally written the Chinese way ("①②④⑦ → ⑤"), a full-screen role card at the start and "X阵营 胜利" at the end. For watching agents it adds a god view (each seat's role tag, the night's moves and each agent's reasoning in grey italics), a log, and a timeline split by nights and days with speed control. Replays play night by night. In a room (`viewer/index.html?live`) a seated person gets their role card, types a speech, or taps a seat and confirms to vote, heal, inspect or pick a victim. A spectator sees only what is public until the game ends. To watch a replay from a clone, run `python3 -m http.server` and open `viewer/index.html?replay=../replays/<file>`. |
+8 −0 field/evidence/main.py 新增 查看文件
| @@ -0,0 +1,8 @@ | ||
| 1 | +"""An offline opponent that reasons from its own lawful evidence, never the referee's deal.""" | |
| 2 | +import os | |
| 3 | +import sys | |
| 4 | + | |
| 5 | +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'referee')) | |
| 6 | +from rule_agent import RuleAgent | |
| 7 | + | |
| 8 | +agent = RuleAgent() | |
+8 −7 kit/room.py 查看文件
| @@ -64,7 +64,7 @@ class Room: | ||
| 64 | 64 | spec = self.seats.get(seat) or {} |
| 65 | 65 | kind = spec.get('kind') |
| 66 | 66 | if kind == 'bot': |
| 67 | - child = self._bot(spec) | |
| 67 | + child = self._bot(spec, seat=seat) | |
| 68 | 68 | move = child.ask(bot if bot is not None else {'obs': obs, 'legal': legal}) if child else None |
| 69 | 69 | move = ok(move) if move is not None else None |
| 70 | 70 | return auto() if move is None else move # a late, broken or illegal bot move: auto plays it |
| @@ -128,16 +128,17 @@ class Room: | ||
| 128 | 128 | if msg.get('act') is not None: |
| 129 | 129 | log(f'ignored act {json.dumps(msg["act"])[:120]}: stale') |
| 130 | 130 | |
| 131 | - def _bot(self, spec): | |
| 132 | - """The child for a bot seat, one per folder (as the checks share one between seats); None if it cannot start.""" | |
| 131 | + def _bot(self, spec, seat=None): | |
| 132 | + """One process per seat and folder: a bot must never accumulate another seat's hidden observations.""" | |
| 133 | 133 | if spec.get('field'): |
| 134 | 134 | folder = os.path.join(self.field, spec['field']) |
| 135 | 135 | else: |
| 136 | 136 | folder = os.path.join(os.environ.get('SS_INPUT', '.'), spec.get('dir') or spec.get('route') or '') |
| 137 | - if folder not in self.bots: | |
| 137 | + key = (seat, folder) | |
| 138 | + if key not in self.bots: | |
| 138 | 139 | try: |
| 139 | - self.bots[folder] = self.start(folder) | |
| 140 | + self.bots[key] = self.start(folder) | |
| 140 | 141 | except (Exception, SystemExit) as e: # a missing bot is played by auto rather than ending the room |
| 141 | 142 | log(f'no bot at {folder} ({e}): auto plays that seat') |
| 142 | - self.bots[folder] = None | |
| 143 | - return self.bots[folder] | |
| 143 | + self.bots[key] = None | |
| 144 | + return self.bots[key] | |
+27 −0 kit/test_room.py 查看文件
| @@ -55,3 +55,30 @@ def test_room(): | ||
| 55 | 55 | if __name__ == '__main__': |
| 56 | 56 | test_room() |
| 57 | 57 | print('room protocol: ok') |
| 58 | + | |
| 59 | + | |
| 60 | +def test_bot_processes_are_private_to_seats(): | |
| 61 | + # Same strategy, different seats: neither process can remember the other seat's hand. | |
| 62 | + from room import Room | |
| 63 | + class Child: | |
| 64 | + def __init__(self): | |
| 65 | + self.seen = [] | |
| 66 | + def ask(self, msg): | |
| 67 | + self.seen.append(msg) | |
| 68 | + return 'a' | |
| 69 | + def close(self): | |
| 70 | + pass | |
| 71 | + room = object.__new__(Room) | |
| 72 | + room.start, room.field, room.bots = lambda folder: Child(), 'field', {} | |
| 73 | + a = room._bot({'field': 'same'}, seat='0') | |
| 74 | + b = room._bot({'field': 'same'}, seat='1') | |
| 75 | + assert a is not b | |
| 76 | + assert room._bot({'field': 'same'}, seat='0') is a | |
| 77 | + a.ask({'hand': 'private'}) | |
| 78 | + assert b.seen == [] | |
| 79 | + room.close() | |
| 80 | + | |
| 81 | + | |
| 82 | +if __name__ == '__main__': | |
| 83 | + test_bot_processes_are_private_to_seats() | |
| 84 | + print('seat process isolation: ok') | |
+71 −0 referee/compare.py 新增 查看文件
| @@ -0,0 +1,71 @@ | ||
| 1 | +"""Bounded, paired local strength audit; never reads or writes the leaderboard. | |
| 2 | + | |
| 3 | +The real pinned engine runs each game. Per-seat RNG state is isolated to match | |
| 4 | +the child adapter's random.seed(0), so one policy cannot change referee random | |
| 5 | +draws or another player's random stream. No model/network calls are made. | |
| 6 | + | |
| 7 | + .venvs/werewolf/bin/python games/werewolf/referee/compare.py --rounds 20 | |
| 8 | +""" | |
| 9 | +import argparse | |
| 10 | +from collections import defaultdict | |
| 11 | +import contextlib | |
| 12 | +import json | |
| 13 | +import random | |
| 14 | +import sys | |
| 15 | +import time | |
| 16 | + | |
| 17 | +with contextlib.redirect_stdout(sys.stderr): | |
| 18 | + from kaggle_environments import make | |
| 19 | + from kaggle_environments.envs.werewolf.werewolf import deterministic_agent, random_agent | |
| 20 | +from play import configuration | |
| 21 | +from rule_agent import RuleAgent | |
| 22 | + | |
| 23 | + | |
| 24 | +class Isolated: | |
| 25 | + def __init__(self, fn): | |
| 26 | + self.fn, self.state = fn, random.Random(0).getstate() | |
| 27 | + | |
| 28 | + def __call__(self, obs, config=None): | |
| 29 | + outer = random.getstate() | |
| 30 | + random.setstate(self.state) | |
| 31 | + try: | |
| 32 | + return self.fn(obs) | |
| 33 | + finally: | |
| 34 | + self.state = random.getstate() | |
| 35 | + random.setstate(outer) | |
| 36 | + | |
| 37 | + | |
| 38 | +def main(): | |
| 39 | + parser = argparse.ArgumentParser() | |
| 40 | + parser.add_argument('--rounds', type=int, default=20, choices=range(1, 101), metavar='1..100') | |
| 41 | + parser.add_argument('--policy', choices=['both', 'old', 'evidence'], default='both') | |
| 42 | + parser.add_argument('--opponent', choices=['all', 'deterministic', 'random', 'evidence'], default='all') | |
| 43 | + args = parser.parse_args() | |
| 44 | + started = time.monotonic() | |
| 45 | + for opponent, fn in [('deterministic', deterministic_agent), ('random', random_agent), ('evidence', None)]: | |
| 46 | + if args.opponent not in ('all', opponent): | |
| 47 | + continue | |
| 48 | + for name, factory in [('old', lambda: Isolated(deterministic_agent)), ('evidence', RuleAgent)]: | |
| 49 | + if args.policy not in ('both', name): | |
| 50 | + continue | |
| 51 | + by = defaultdict(list) | |
| 52 | + for repeat in range(args.rounds): | |
| 53 | + for mine in range(7): | |
| 54 | + seed = repeat + 900 | |
| 55 | + random.seed(seed) | |
| 56 | + conf = configuration(seed, mine, rng=random.Random(9000 + repeat * 7 + mine)) | |
| 57 | + env = make('werewolf', configuration=conf) | |
| 58 | + players = [Isolated(fn) if fn else RuleAgent() for _ in range(7)] | |
| 59 | + players[mine] = factory() | |
| 60 | + env.run(players) | |
| 61 | + if not env.done or env.info['GAME_END']['terminated_with_agent_error']: | |
| 62 | + raise RuntimeError(f'Invalid game: opponent={opponent}, policy={name}, round={repeat}, seat={mine}') | |
| 63 | + by[conf['agents'][mine]['role']].append(int((env.state[mine].reward or 0) > 0)) | |
| 64 | + print(json.dumps({'opponent': opponent, 'policy': name, 'wins': sum(map(sum, by.values())), | |
| 65 | + 'games': sum(map(len, by.values())), | |
| 66 | + 'roles': {r: {'wins': sum(v), 'games': len(v)} for r, v in by.items()}}), flush=True) | |
| 67 | + print(json.dumps({'elapsed_seconds': round(time.monotonic() - started, 3)})) | |
| 68 | + | |
| 69 | + | |
| 70 | +if __name__ == '__main__': | |
| 71 | + main() | |
+3 −1 referee/kaggle_seat.py 查看文件
| @@ -5,6 +5,7 @@ import json, os, sys | ||
| 5 | 5 | HERE = os.path.dirname(os.path.abspath(__file__)) |
| 6 | 6 | sys.path.insert(0, os.path.join(os.path.dirname(HERE), 'kit')) |
| 7 | 7 | from host import Child # noqa: E402 |
| 8 | +from rule_agent import public_config # noqa: E402 | |
| 8 | 9 | |
| 9 | 10 | |
| 10 | 11 | def seat(folder, limit=1.0, idle=None, start=30): |
| @@ -14,7 +15,8 @@ def seat(folder, limit=1.0, idle=None, start=30): | ||
| 14 | 15 | child = Child([sys.executable, '-s', os.path.join(HERE, 'kaggle_child.py'), main], cwd=folder, limit=limit, start=start) |
| 15 | 16 | |
| 16 | 17 | def agent(obs, config): |
| 17 | - answer = child.ask({'obs': json.loads(json.dumps(obs, default=str)), 'config': json.loads(json.dumps(config, default=str))}) | |
| 18 | + answer = child.ask({'obs': json.loads(json.dumps(obs, default=str)), | |
| 19 | + 'config': public_config(json.loads(json.dumps(config, default=str)))}) | |
| 18 | 20 | return idle if answer is None else answer |
| 19 | 21 | |
| 20 | 22 | agent.child = child |
+24 −4 referee/play.py 查看文件
| @@ -1,6 +1,6 @@ | ||
| 1 | -"""The project's referee, on kaggle_environments' own Werewolf (the game Kaggle Game Arena plays): seven players, two | |
| 1 | +"""The project's referee, on kaggle_environments 1.33.0's seven-player Werewolf preset: two | |
| 2 | 2 | werewolves against a seer, a doctor and three villagers, nights of killing, healing and inspecting, days of talk and |
| 3 | -votes. Seats take roles in order. check: on three seeds, the route's bot takes every seat in turn and the opponent | |
| 3 | +votes. Version 2 privately shuffles each deal. check: in three balanced rounds, the route's bot takes every seat and role and the opponent | |
| 4 | 4 | fills the other six; the score is the share of games the route's team wins, each role a subtask (over the field). |
| 5 | 5 | practice: one seed against each opponent, the route as a werewolf and as the seer, a replay of each in replays/. |
| 6 | 6 | python3 -s referee/play.py check|practice <route folder> |
| @@ -15,6 +15,25 @@ from kaggle_environments import make # noqa: E402 | ||
| 15 | 15 | from kaggle_seat import seat # noqa: E402 |
| 16 | 16 | |
| 17 | 17 | N, SEEDS = 7, [3, 5, 8] |
| 18 | +ROLES = ['Werewolf', 'Werewolf', 'Seer', 'Doctor', 'Villager', 'Villager', 'Villager'] | |
| 19 | + | |
| 20 | + | |
| 21 | +def configuration(seed, mine=None, rng=None): | |
| 22 | + """Independent private deal; candidate covers each role once per seven games. | |
| 23 | + | |
| 24 | + Gameplay seeds alone must not reconstruct the hidden assignment. The test | |
| 25 | + helper may inject a local RNG for repeatable audits, never a player input. | |
| 26 | + """ | |
| 27 | + rng = rng or random.SystemRandom() | |
| 28 | + roles = list(ROLES) | |
| 29 | + if mine is None: | |
| 30 | + rng.shuffle(roles) | |
| 31 | + else: | |
| 32 | + fixed = roles.pop(mine) | |
| 33 | + rng.shuffle(roles) | |
| 34 | + roles.insert(mine, fixed) | |
| 35 | + return {'seed': seed, 'agents': [{'id': f'Player {i + 1}', 'role': r, 'agent_id': 'seat'} | |
| 36 | + for i, r in enumerate(roles)]} | |
| 18 | 37 | |
| 19 | 38 | |
| 20 | 39 | GOD = 'MODERATOR' |
| @@ -125,7 +144,8 @@ class Story: | ||
| 125 | 144 | if v is not None: |
| 126 | 145 | counts[v] = counts.get(v, 0) + 1 |
| 127 | 146 | top = [s for s, c in counts.items() if c == max(counts.values())] if counts else [] |
| 128 | - return {'votes': dict(self.votes), 'counts': counts, 'tie': top if len(top) > 1 else None} | |
| 147 | + return {'votes': dict(self.votes), 'counts': counts, 'tie': top if len(top) > 1 else None, | |
| 148 | + 'all_abstained': bool(self.votes) and not counts} | |
| 129 | 149 | |
| 130 | 150 | def fall(self, s, cause, day): |
| 131 | 151 | e = {'who': s, 'cause': cause, 'role': self.roles[s], 'team': self.teams[s], 'day': day} |
| @@ -161,7 +181,7 @@ def main(mode, route): | ||
| 161 | 181 | for mine in range(N) if mode == 'check' else (0, 2): |
| 162 | 182 | agents = others[:mine] + [me] + others[mine:] |
| 163 | 183 | random.seed(sd) |
| 164 | - env = make('werewolf', configuration={'seed': sd}) | |
| 184 | + env = make('werewolf', configuration=configuration(sd, mine)) | |
| 165 | 185 | env.run(agents) |
| 166 | 186 | j = env.toJSON() |
| 167 | 187 | role = j['configuration']['agents'][mine]['role'] |
+14 −8 referee/room.py 查看文件
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | seats, speaking the room protocol through kit/room.py. Seat "0"-"6" is Player 1-7 and the engine deals the roles |
| 3 | 3 | (seeded by SS_ROOM's seed, else at random). A bot runs a route's or a field folder's main.py in a process of its own per |
| 4 | 4 | seat, as in the checks, 3 s a move; a seat on auto, a person out of time and a bot that fails or answers out of turn |
| 5 | -are played by the engine's deterministic agent. Public things are events; what one seat alone may know (its role, the | |
| 5 | +are played by the offline evidence-based computer, with a private journal per seat. Public things are events; what one seat alone may know (its role, the | |
| 6 | 6 | wolves' picks, the seer's results, the doctor's heals) is told to that seat. At the end the game is left in replays/ in |
| 7 | 7 | the practice replays' format with a matches.json entry, so it lists as a match. |
| 8 | 8 | |
| @@ -20,7 +20,7 @@ Messages (events are play.py's Story events, seats 0-6, without the private reas | ||
| 20 | 20 | ones so far (the last 40), raw Kaggle's own raw_observation, so a Kaggle agent can play from it |
| 21 | 21 | chat: legal [{type: chat, message: ""}], answered with the message filled in (trimmed, at most 600 characters; |
| 22 | 22 | left empty, the seat passes) |
| 23 | - kill|heal|inspect|vote: legal [{type, target: seat}], a day vote also {type: vote, target: null} to abstain | |
| 23 | + kill|heal|inspect|vote: legal [{type, target: seat}], day and night votes also {type: vote|kill, target: null} to abstain | |
| 24 | 24 | an answer may add "reasoning" (kept for the replay), or be the Kaggle agent's own action, as main.py returns it |
| 25 | 25 | event {kind: over, winner, reason, roles, out}, then end {result, scores}: 1 for each seat of the winning team |
| 26 | 26 | """ |
| @@ -32,9 +32,9 @@ from room import Room, Stop # noqa: E402 (first: play.py puts referee/ ahead o | ||
| 32 | 32 | import league # noqa: E402 |
| 33 | 33 | from kaggle_environments import make # noqa: E402 |
| 34 | 34 | from kaggle_environments.envs.werewolf.game.actions import ChatAction, HealAction, InspectAction, VoteAction # noqa: E402 |
| 35 | -from kaggle_environments.envs.werewolf.werewolf import deterministic_agent # noqa: E402 | |
| 36 | 35 | from kaggle_seat import seat as bot_seat # noqa: E402 |
| 37 | 36 | from play import N, Story, transcript # noqa: E402 |
| 37 | +from rule_agent import RuleAgent, public_config # noqa: E402 | |
| 38 | 38 | |
| 39 | 39 | SAY, PASS = 600, 'I pass.' |
| 40 | 40 | ACTION = {'chat': ChatAction, 'vote': VoteAction, 'kill': VoteAction, 'heal': HealAction, 'inspect': InspectAction} |
| @@ -71,7 +71,9 @@ def request(raw): | ||
| 71 | 71 | if phase == 'DAY_CHAT_AWAIT': |
| 72 | 72 | return 'chat', [] |
| 73 | 73 | if phase == 'DAY_VOTING_AWAIT': |
| 74 | - return 'vote', others | |
| 74 | + # SequentialVoting's potential targets include the voter. The upstream | |
| 75 | + # example agent avoids self-votes, but that is a policy, not a rule. | |
| 76 | + return 'vote', list(raw['alive_players']) | |
| 75 | 77 | if phase == 'NIGHT_AWAIT_ACTIONS': |
| 76 | 78 | if role == 'Werewolf': |
| 77 | 79 | return 'kill', listed('vote_request', 'valid_targets') or others |
| @@ -89,7 +91,8 @@ def main(): | ||
| 89 | 91 | room = Room(Bots, field=os.path.join(ROOT, 'field')) |
| 90 | 92 | env = make('werewolf', configuration={'seed': seed, 'randomize_roles': True}) |
| 91 | 93 | env.reset(N) |
| 92 | - config = json.loads(json.dumps(env.configuration, default=str)) | |
| 94 | + config = public_config(json.loads(json.dumps(env.configuration, default=str))) | |
| 95 | + stand_ins = [RuleAgent() for _ in range(N)] | |
| 93 | 96 | seats = [str(i) for i in range(N)] |
| 94 | 97 | ids = [p.id for p in env.game_state.players] |
| 95 | 98 | role = [str(p.role.name) for p in env.game_state.players] |
| @@ -122,13 +125,16 @@ def main(): | ||
| 122 | 125 | obs = json.loads(json.dumps({**env.state[i].observation, 'step': env.state[0].observation.step}, default=str)) |
| 123 | 126 | raw = obs['raw_observation'] |
| 124 | 127 | what, targets = request(raw) |
| 125 | - stand_in = lambda: deterministic_agent(obs) # noqa: E731 | |
| 128 | + # Observe every turn, including turns a human handles, so a later | |
| 129 | + # timeout does not forget that seat's earlier private knowledge. | |
| 130 | + stand_ins[i].observe(obs) | |
| 131 | + stand_in = lambda: stand_ins[i](obs) # noqa: E731 | |
| 126 | 132 | if what is None: |
| 127 | 133 | return stand_in() # nothing asked: the engine's no-op |
| 128 | 134 | seat_of = {p: str(ids.index(p)) for p in ids} |
| 129 | 135 | aim = [seat_of[p] for p in targets if p in seat_of] |
| 130 | 136 | legal = [{'type': 'chat', 'message': ''}] if what == 'chat' else \ |
| 131 | - [{'type': what, 'target': t} for t in aim] + ([{'type': 'vote', 'target': None}] if what == 'vote' else []) | |
| 137 | + [{'type': what, 'target': t} for t in aim] + ([{'type': what, 'target': None}] if what in ('vote', 'kill') else []) | |
| 132 | 138 | base = {'day': raw['day'], 'phase': raw['game_state_phase'], 'actor_id': raw['player_id']} |
| 133 | 139 | |
| 134 | 140 | def accept(a): |
| @@ -150,7 +156,7 @@ def main(): | ||
| 150 | 156 | return ChatAction(**kw, message=text.strip()[:SAY] or PASS).serialize() if isinstance(text, str) else None |
| 151 | 157 | t = a.get('target') |
| 152 | 158 | t = None if t is None else str(t) |
| 153 | - if (t is None and what != 'vote') or (t is not None and t not in aim): | |
| 159 | + if (t is None and what not in ('vote', 'kill')) or (t is not None and t not in aim): | |
| 154 | 160 | return None |
| 155 | 161 | return ACTION[what](**kw, target_id='-1' if t is None else ids[int(t)]).serialize() |
| 156 | 162 | |
+275 −0 referee/rule_agent.py 新增 查看文件
| @@ -0,0 +1,275 @@ | ||
| 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 | + # Claims are fallible evidence. Never parse a question, negation, or a | |
| 81 | + # mere accusation as a confirmed inspection; human text has no authority. | |
| 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 | + | |
| 105 | + def wolf(role): | |
| 106 | + return role in ('Werewolf', 'Werewolves') | |
| 107 | + | |
| 108 | + def tie(self, pid): | |
| 109 | + # Public, reproducible tiebreak; no dependence on a referee seed or IDs | |
| 110 | + # being assigned particular roles. Different seats explore differently. | |
| 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 | + # A true seer does not fabricate a failed inspection. Other | |
| 127 | + # good players may bluff, so public claims never remove a team. | |
| 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 | + # Revisit earlier ballots only after a role is revealed. A vote | |
| 140 | + # against a wolf is useful evidence, not an innocence certificate. | |
| 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 | + # Duplicate role claims remain contested. | |
| 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 | + # Refuse to make a known innocent the target just to join a wagon. | |
| 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 | + # Counterclaim only when a living seer's report threatens our team; | |
| 224 | + # the fabricated result is public deception, never a hidden fact. | |
| 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', '当前没有本身份可执行的动作。') | |
+235 −0 referee/test_rules.py 新增 查看文件
| @@ -0,0 +1,235 @@ | ||
| 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) | |
+7 −14 scienceslack.json 查看文件
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | - "description": "Kaggle Game Arena's Werewolf on kaggle-environments' own engine: seven players, two werewolves. Bring a Werewolf agent; it plays every role against the field.", | |
| 2 | + "description": "Seven-player Werewolf on kaggle-environments 1.33.0: sequential votes, random ties, repeat doctor protection. Play online, your agent, platform bots, or the evidence-based offline computer.", | |
| 3 | 3 | "image": "python:3.13-slim", |
| 4 | 4 | "cover": { |
| 5 | 5 | "file": "cover.png" |
| @@ -48,7 +48,8 @@ | ||
| 48 | 48 | "id": "6", |
| 49 | 49 | "label": "7号" |
| 50 | 50 | } |
| 51 | - ] | |
| 51 | + ], | |
| 52 | + "computer": "evidence" | |
| 52 | 53 | }, |
| 53 | 54 | "benchmark": { |
| 54 | 55 | "task": "Werewolf", |
| @@ -58,22 +59,14 @@ | ||
| 58 | 59 | }, |
| 59 | 60 | "subtasks": "roles", |
| 60 | 61 | "aggregate": "mean", |
| 61 | - "baseline": { | |
| 62 | - "name": "Starter (deterministic)", | |
| 63 | - "score": 0.642857, | |
| 64 | - "subtasks": { | |
| 65 | - "doctor": 0.5, | |
| 66 | - "seer": 0.833333, | |
| 67 | - "villager": 0.722222, | |
| 68 | - "werewolf": 0.5 | |
| 69 | - } | |
| 70 | - }, | |
| 71 | 62 | "guardrails": [ |
| 72 | 63 | "Each move within 3 seconds", |
| 73 | 64 | "The agent is its own process and sees only what its role may see", |
| 74 | - "No network: an agent calls no model here" | |
| 65 | + "No network: an agent calls no model here", | |
| 66 | + "Private independent role deals; seat processes receive public configuration only", | |
| 67 | + "Version 2 scores do not compare with the fixed-role version 1 baseline" | |
| 75 | 68 | ], |
| 76 | - "version": 1 | |
| 69 | + "version": 2 | |
| 77 | 70 | }, |
| 78 | 71 | "checks": { |
| 79 | 72 | "play": { |
+13 −9 viewer/index.html 查看文件
| @@ -478,9 +478,9 @@ const live = new URLSearchParams(location.search).has('live') | ||
| 478 | 478 | const $ = s => document.querySelector(s) |
| 479 | 479 | const app = $('#app') |
| 480 | 480 | const ROLE = { |
| 481 | - Werewolf: { zh: '狼人', img: 'werewolf', team: 'Werewolves', skill: '每晚和狼队友商量,选一名玩家出局。', goal: '白天装作好人混淆视听,夜里刀人;狼人数量追平好人即胜利。' }, | |
| 481 | + Werewolf: { zh: '狼人', img: 'werewolf', team: 'Werewolves', skill: '每晚依次投票刀一名好人,可看到狼队友已投的票;平票随机定刀。', goal: '白天装作好人混淆视听,夜里刀人;狼人数量追平好人即胜利。' }, | |
| 482 | 482 | Seer: { zh: '预言家', img: 'seer', team: 'Villagers', skill: '每晚查验一名玩家,得知他的真实身份。', goal: '找出狼人,带领好人把他们放逐。' }, |
| 483 | - Doctor: { zh: '医生', img: 'doctor', team: 'Villagers', skill: '每晚守护一名玩家(不能守自己),他当晚不会被狼人杀死。', goal: '保护好人,放逐所有狼人。' }, | |
| 483 | + Doctor: { zh: '医生', img: 'doctor', team: 'Villagers', skill: '每晚守护一名玩家(不能守自己,可以连续守同一人),他当晚不会被狼人杀死。', goal: '保护好人,放逐所有狼人。' }, | |
| 484 | 484 | Villager: { zh: '平民', img: 'villager', team: 'Villagers', skill: '没有特殊能力,白天发言、投票。', goal: '听发言找出狼人,放逐所有狼人。' }, |
| 485 | 485 | } |
| 486 | 486 | const TEAM = { Werewolves: '狼人阵营', Villagers: '好人阵营' } |
| @@ -631,7 +631,7 @@ function board(S, e, fresh) { | ||
| 631 | 631 | if (!rows.length && !by.x) h += `<div class="br"><span class="tg" style="color:var(--muted)">等待第一张票……</span></div>` |
| 632 | 632 | if (T) { |
| 633 | 633 | const top = rows.length ? rows[0][1].length : 0 |
| 634 | - const res = T.exiled == null ? '无人被放逐' : T.tie ? `${T.tie.map(no).join('和')}平票,随机抽中${no(T.exiled)}` : `${no(T.exiled)}被放逐(${top}票)` | |
| 634 | + const res = T.exiled == null ? '无人被放逐' : (T.all_abstained || (!top && Object.keys(votes).length)) ? `全部弃票,按本桌规则随机放逐${no(T.exiled)}` : T.tie ? `${T.tie.map(no).join('和')}平票,随机抽中${no(T.exiled)}` : `${no(T.exiled)}被放逐(${top}票)` | |
| 635 | 635 | h += `<div class="res">${res}</div><div class="sum">${rows.map(([t, vs]) => `${vs.map(v => CIRCLED[v]).join('')} → ${CIRCLED[t]}`).join(' ')}</div>` |
| 636 | 636 | } |
| 637 | 637 | return h + '</div>' |
| @@ -644,7 +644,7 @@ function nightCard(e) { | ||
| 644 | 644 | heal: () => line('doctor', 'shield', `${no(e.actor)}守护了${no(e.target)}`), |
| 645 | 645 | inspect: () => line('seer', 'eye', `${no(e.actor)}查验${no(e.target)}:<span class="role ${e.role}">${zh(e.role)}</span>`), |
| 646 | 646 | wolf_vote: () => line('wolf', 'claw', `${no(e.actor)}${e.target == null ? '没有选择' : `选择了${no(e.target)}`}`), |
| 647 | - wolf_pick: () => line('wolf', 'claw', `狼队选择了${e.target == null ? '空刀' : no(e.target)}`) + (e.tie ? `<p class="god">${e.tie.map(no).join('、')}票数相同,随机定刀</p>` : ''), | |
| 647 | + wolf_pick: () => line('wolf', 'claw', `狼队选择了${e.target == null ? '空刀' : no(e.target)}`) + (e.all_abstained ? '<p class="god">全部弃票,按本桌规则随机定刀</p>' : e.tie ? `<p class="god">${e.tie.map(no).join('、')}票数相同,随机定刀</p>` : ''), | |
| 648 | 648 | saved: () => line('doctor', 'shield', `医生守住了${no(e.target)}`), |
| 649 | 649 | }[e.kind]() |
| 650 | 650 | return judge(call, body) |
| @@ -656,7 +656,7 @@ function nightAsk(S) { // a seat woken at night: the judge's call and what this | ||
| 656 | 656 | const picks = mates.filter(s => S.picks[s] != null).map(s => `${no(s)}选择了${no(S.picks[s])}`).join(',') |
| 657 | 657 | return judge('狼人请睁眼', `<p>点击头像,选择今晚要刀的人</p>${mates.length ? `<p class="god">狼队友:${mates.map(no).join('')}${picks ? `;${picks}` : ''}</p>` : ''}`) |
| 658 | 658 | } |
| 659 | - if (w === 'heal') return judge('医生请睁眼', '<p>点击头像,选择今晚要守护的人(不能守自己)</p>') | |
| 659 | + if (w === 'heal') return judge('医生请睁眼', '<p>点击头像,选择今晚要守护的人(不能守自己,可以连续守同一人)</p>') | |
| 660 | 660 | const known = E.filter(e => e.kind === 'inspect' && e._to === A.to).map(e => `${no(e.target)}${e.role === 'Werewolf' ? '是狼人' : '是好人'}`).join(',') |
| 661 | 661 | return judge('预言家请睁眼', `<p>点击头像,选择要查验的人</p>${known ? `<p class="god">你已查验:${known}</p>` : ''}`) |
| 662 | 662 | } |
| @@ -679,7 +679,7 @@ function sceneHTML(S, e, i, fresh) { | ||
| 679 | 679 | case 'talk': return judge(e.order?.length ? `从${no(e.order[0])}开始发言` : '开始发言', '<p>按顺序,每人发言一次</p>', e.en) |
| 680 | 680 | case 'speech': return bubble(e.actor, godOn() ? e.why : '', false) |
| 681 | 681 | case 'turn': return e.what === 'chat' ? bubble(e.seat, '', true) : board(S, e, fresh) |
| 682 | - case 'vote_start': return judge('发言结束,开始放逐投票', '<p>依次投票,得票最多的人出局</p>', e.en) | |
| 682 | + case 'vote_start': return judge('发言结束,开始放逐投票', '<p>依次投票,得票最多的人出局;平票随机,全部弃票也随机放逐一人</p>', e.en) | |
| 683 | 683 | case 'vote': case 'tally': return board(S, e, fresh) |
| 684 | 684 | case 'over': return judge(e.winner ? `${TEAM[e.winner]} 胜利` : '对局中止', `<p>${reasonZh(e)}</p>`, e.en) |
| 685 | 685 | default: return nightCard(e) |
| @@ -804,7 +804,11 @@ function roleCard(c) { | ||
| 804 | 804 | $('#over').className = 'on' |
| 805 | 805 | } |
| 806 | 806 | function about() { |
| 807 | - $('#over').innerHTML = `<div class="card about"><h2>关于这张桌子</h2><p>规则和引擎:Google 的 kaggle-environments Werewolf(Apache-2.0)。</p>` + | |
| 807 | + $('#over').innerHTML = `<div class="card about"><h2>本桌规则</h2><p>kaggle-environments 1.33.0 七人默认变体:2 狼人、1 预言家、1 医生、3 平民。</p>` + | |
| 808 | + `<p>夜间狼队依次投票,平票随机定刀;医生不能自守,可以连续守同一人;预言家查验真实身份。</p>` + | |
| 809 | + `<p>白天每人依次发言一轮,再依次投票。最高票出局,平票随机;全部弃票仍随机放逐一人。出局时公布身份。狼人全灭则好人胜,狼人数量追平好人则狼人胜。</p>` + | |
| 810 | + `<p>本桌不等同于 Kaggle Game Arena 当前的八人规则,也不是女巫、猎人或警长模式。固定电脑按可见证据行动,发言中的身份声明可能是伪装。</p>` + | |
| 811 | + `<p>引擎:Google kaggle-environments(Apache-2.0)。</p>` + | |
| 808 | 812 | `<p>角色图和小图标:Microsoft Fluent Emoji(MIT)。头像:DiceBear Notionists,原作 Zoish(CC0)。</p>` + |
| 809 | 813 | `<p>标记图标:game-icons.net,作者 Lorc、Delapouite(CC BY 3.0)。法官字体:Noto Serif SC 子集(SIL OFL 1.1)。</p>` + |
| 810 | 814 | `<p>详见 viewer/NOTICE。</p><div style="text-align:center"><button class="btn" data-k="close">好的</button></div></div>` |
| @@ -999,9 +1003,9 @@ function drawAct(S) { | ||
| 999 | 1003 | $('#pass').onclick = () => answer({ type: 'chat', message: '' }) |
| 1000 | 1004 | } else if (A) { |
| 1001 | 1005 | const w = A.obs.request |
| 1002 | - if (pick == null) f.innerHTML = `<div class="act"><div class="clock" id="clock"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15"/><circle class="fg" cx="18" cy="18" r="15" stroke-dasharray="94.25"/></svg><span></span></div><div class="msg">${ASKS[w]}</div>${w === 'vote' ? '<button class="btn ghost" id="abst">弃票</button>' : ''}</div>` | |
| 1006 | + if (pick == null) f.innerHTML = `<div class="act"><div class="clock" id="clock"><svg viewBox="0 0 36 36"><circle class="bg" cx="18" cy="18" r="15"/><circle class="fg" cx="18" cy="18" r="15" stroke-dasharray="94.25"/></svg><span></span></div><div class="msg">${ASKS[w]}</div>${A.legal.some(a => Object.hasOwn(a, 'target') && a.target == null) ? '<button class="btn ghost" id="abst" title="全部弃票仍随机选择一人">弃票</button>' : ''}</div>` | |
| 1003 | 1007 | else f.innerHTML = `<div class="act"><div class="msg">${VERB[w]}${no(pick)}${esc(P[pick].name)}?</div><button class="btn ghost" id="redo">换人</button><button class="btn${w === 'vote' || w === 'kill' ? ' red' : ''}" id="yes">确认${w === 'vote' ? '投票' : ''}</button></div>` |
| 1004 | - if ($('#abst')) $('#abst').onclick = () => answer({ type: 'vote', target: null }) | |
| 1008 | + if ($('#abst')) $('#abst').onclick = () => answer({ type: w, target: null }) | |
| 1005 | 1009 | if ($('#redo')) $('#redo').onclick = () => { pick = null; render(E.length) } |
| 1006 | 1010 | if ($('#yes')) $('#yes').onclick = () => answer(A.legal.find(a => a.target != null && +a.target === pick)) |
| 1007 | 1011 | } else if (sent) f.innerHTML = msg(sent.type === 'chat' ? '发言已送出,等待其他人……' : `已${VERB[sent.type]}${sent.target == null ? '(弃票)' : no(sent.target)},等待其他人……`) |
