diff --git a/CHANGELOG.md b/CHANGELOG.md index 2990902..c0b9086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] ### Added +- **Bidding-based discussion** ([#19](https://github.com/JuneQQQ/deepwolf/issues/19)) + — a new `discussion_mode="bidding"` (and a `--bidding` CLI flag) where agents + bid for the discussion floor each round instead of speaking in fixed seating + order; highest bid speaks first. Inspired by Google's Werewolf Arena — but + deepwolf's twist is that every bid (priority *and* a public reason) is emitted + as an event, so an eager bid with a thin reason is itself a readable signal. + The default stays `"ordered"`, so existing games are unchanged. - **Arena leaderboard** ([#4](https://github.com/JuneQQQ/deepwolf/issues/4)) — `deepwolf.arena.leaderboard` and a `deepwolf leaderboard` command rank agents fairly: each competitor plays both sides of a fixed reference match-up under diff --git a/README.md b/README.md index 37d8354..dc86f20 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ to a human as a copilot. illegal or hallucinated agent move can never corrupt a game. - 🌏 **Bilingual.** The whole game — event log, roles, agent speech, the CLI — runs in English or Simplified Chinese. Just add `--lang zh`. +- 🗣️ **Adaptive discussion.** Optionally let agents *bid* for the floor each + round (`--bidding`) — bids are public, so wanting to talk is itself a tell. - 🔌 **Vendor-neutral LLMs.** Any OpenAI-compatible endpoint — OpenAI, DeepSeek, Xiaomi MiMo, Groq, OpenRouter, a local server. Change one env var. - 🧪 **Offline by default.** A deterministic `MockProvider` plays full games @@ -62,6 +64,7 @@ Watch a full game play itself — **no API key needed**: ```bash deepwolf simulate --players 7 --seed 1 deepwolf simulate --players 7 --seed 1 --lang zh # play in Chinese +deepwolf simulate --players 7 --seed 1 --bidding # agents bid to speak deepwolf simulate --players 7 --seed 1 --transcript game.json # + JSON record ``` diff --git a/deepwolf/agents/base.py b/deepwolf/agents/base.py index 142c964..03a3e19 100644 --- a/deepwolf/agents/base.py +++ b/deepwolf/agents/base.py @@ -49,6 +49,15 @@ def dying_shot(self, view: PlayerView) -> int: pool = view.others_alive() or list(view.living_ids) return view.rng.choice(pool) + def bid(self, view: PlayerView) -> tuple[int, str]: + """Return ``(priority, reason)`` — a bid for the discussion floor. + + Only called in the ``bidding`` discussion mode. ``priority`` is clamped + to 0-10 by the engine; ``reason`` is a short public justification. The + default is a neutral bid; LLM and human agents override it. + """ + return (5, "") + def witch_turn( self, view: PlayerView, diff --git a/deepwolf/agents/llm_agent.py b/deepwolf/agents/llm_agent.py index 5430418..af07ecf 100644 --- a/deepwolf/agents/llm_agent.py +++ b/deepwolf/agents/llm_agent.py @@ -60,6 +60,18 @@ def dying_shot(self, view: PlayerView) -> int: candidates = view.others_alive() or list(view.living_ids) return self._decide(view, T.KIND_SHOOT, candidates) + def bid(self, view: PlayerView) -> tuple[int, str]: + messages = [ + {"role": "system", "content": T.system_message(view)}, + {"role": "user", "content": T.bid_request(view)}, + ] + data = _parse_json(self._call(messages)) + if not data: + return (5, "") + priority = data.get("priority") + reason = str(data.get("reason", "")) if data.get("reason") else "" + return (priority if isinstance(priority, int) else 5, reason) + def witch_turn( self, view: PlayerView, victim: int | None, can_heal: bool, can_poison: bool ) -> tuple[bool, int | None]: diff --git a/deepwolf/agents/random_agent.py b/deepwolf/agents/random_agent.py index 548a8a4..3908b7c 100644 --- a/deepwolf/agents/random_agent.py +++ b/deepwolf/agents/random_agent.py @@ -42,6 +42,9 @@ def vote(self, view: PlayerView) -> int: def speak(self, view: PlayerView) -> str: return view.rng.choice(_FILLER.get(view.lang, _FILLER["en"])) + def bid(self, view: PlayerView) -> tuple[int, str]: + return (view.rng.randint(0, 10), "") + def witch_turn( self, view: PlayerView, victim: int | None, can_heal: bool, can_poison: bool ) -> tuple[bool, int | None]: diff --git a/deepwolf/cli.py b/deepwolf/cli.py index 54a5655..88ec20d 100644 --- a/deepwolf/cli.py +++ b/deepwolf/cli.py @@ -146,6 +146,17 @@ def witch_turn( poison = int(raw) return (heal, poison) + def bid(self, view: PlayerView) -> tuple[int, str]: + self._banner(view, "BID") + raw = self.console.input( + pick(view.lang, " Bid for the floor [0-10]: ", " 为发言权竞价 [0-10]:") + ).strip() + priority = int(raw) if raw.isdigit() else 5 + reason = self.console.input( + pick(view.lang, " reason (optional)> ", " 理由(可选)> ") + ).strip() + return (max(0, min(10, priority)), reason) + def _banner(self, view: PlayerView, phase: str) -> None: self.console.rule(f"You are {view.me_name} (P{view.me_id}) — {view.me_role.value} — {phase}") for note in view.private_notes: @@ -178,6 +189,7 @@ def cmd_simulate(args: argparse.Namespace) -> int: provider = build_provider(args.provider, seed=args.model_seed) config = GameConfig.standard( args.players, seed=args.seed, discussion_rounds=args.rounds, lang=args.lang, + discussion_mode="bidding" if args.bidding else "ordered", ) def factory(player_id: int, role: Role) -> Agent: @@ -225,6 +237,7 @@ def cmd_play(args: argparse.Namespace) -> int: console = _console() config = GameConfig.standard( args.players, seed=args.seed, discussion_rounds=args.rounds, lang=args.lang, + discussion_mode="bidding" if args.bidding else "ordered", ) seat = args.seat if args.seat is not None else (args.seed % args.players) bot_provider = build_provider("mock", seed=args.seed) @@ -310,6 +323,7 @@ def progress(done: int, total: int) -> None: EventType.LYNCH: "red", EventType.HUNTER_SHOT: "bold red", EventType.QUIET_NIGHT: "green", + EventType.SPEAK_BID: "dim cyan", EventType.STATEMENT: "white", EventType.GAME_OVER: "bold green", } @@ -450,6 +464,10 @@ def build_parser() -> argparse.ArgumentParser: "--lang", choices=LANGUAGES, default="en", help="game language: en (English) or zh (中文)", ) + sim.add_argument( + "--bidding", action="store_true", + help="agents bid for the discussion floor instead of fixed order", + ) sim.set_defaults(func=cmd_simulate) arena = sub.add_parser("arena", help="benchmark agents over many games") @@ -476,6 +494,10 @@ def build_parser() -> argparse.ArgumentParser: "--lang", choices=LANGUAGES, default="en", help="game language: en (English) or zh (中文)", ) + play.add_argument( + "--bidding", action="store_true", + help="agents bid for the discussion floor instead of fixed order", + ) play.set_defaults(func=cmd_play) board = sub.add_parser("leaderboard", help="rank agents against a reference") diff --git a/deepwolf/game/engine.py b/deepwolf/game/engine.py index 0e22e51..f1a5c87 100644 --- a/deepwolf/game/engine.py +++ b/deepwolf/game/engine.py @@ -266,7 +266,7 @@ def _day_phase(self) -> None: self.tr.t("day_breaks", day=s.day), )) for _ in range(self.config.discussion_rounds): - for pid in s.living_ids(): + for pid in self._speaking_order(): self._collect_statement(pid) s.phase = Phase.DAY_VOTE @@ -294,6 +294,43 @@ def _day_phase(self) -> None: )) self._process_hunter(lynched) + def _speaking_order(self) -> list[int]: + """Living players in the order they speak this round. + + In ``ordered`` mode this is seating order. In ``bidding`` mode every + agent bids for the floor; the bids (priority + a public reason) are + emitted as events, and speakers are seated highest-bid-first. Surfacing + the bids keeps the round explainable — an eager bid with a thin reason + is itself a signal the copilot and other agents can read. + """ + s = self.state + living = s.living_ids() + if self.config.discussion_mode != "bidding": + return living + + bids: dict[int, int] = {} + for pid in living: + view = build_view(s, pid, Phase.DAY_DISCUSSION) + try: + priority, reason = self.agents[pid].bid(view) + except Exception: # noqa: BLE001 - an agent error must not crash a game + priority, reason = 5, "" + priority = max(0, min(10, priority if isinstance(priority, int) else 5)) + reason = str(reason).strip()[:200] + bids[pid] = priority + if reason: + text = self.tr.t( + "speak_bid_reasoned", who=self._who(pid), + priority=priority, reason=reason, + ) + else: + text = self.tr.t("speak_bid", who=self._who(pid), priority=priority) + self._emit(Event( + EventType.SPEAK_BID, s.day, "day", text, + actor=pid, data={"priority": priority, "reason": reason}, + )) + return sorted(living, key=lambda p: (-bids[p], p)) + def _collect_statement(self, player_id: int) -> None: s = self.state view = build_view(s, player_id, Phase.DAY_DISCUSSION) diff --git a/deepwolf/game/events.py b/deepwolf/game/events.py index 01859d9..fac872e 100644 --- a/deepwolf/game/events.py +++ b/deepwolf/game/events.py @@ -25,6 +25,7 @@ class EventType(str, Enum): DAY_BREAKS = "day_breaks" # public DEATH_ANNOUNCED = "death_announced" # public QUIET_NIGHT = "quiet_night" # public: nobody died + SPEAK_BID = "speak_bid" # public: a bid for the discussion floor STATEMENT = "statement" # public: a daytime statement VOTE_CAST = "vote_cast" # public LYNCH = "lynch" # public diff --git a/deepwolf/game/state.py b/deepwolf/game/state.py index 6b68f04..ae0b64b 100644 --- a/deepwolf/game/state.py +++ b/deepwolf/game/state.py @@ -32,6 +32,7 @@ class GameConfig: player_names: list[str] | None = None seed: int | None = None discussion_rounds: int = 1 + discussion_mode: str = "ordered" # "ordered" (seating) or "bidding" max_days: int = 30 reveal_role_on_death: bool = True lang: str = "en" @@ -48,6 +49,8 @@ def __post_init__(self) -> None: raise ValueError("player_names must match the number of roles") if self.lang not in LANGUAGES: raise ValueError(f"unknown language {self.lang!r}") + if self.discussion_mode not in ("ordered", "bidding"): + raise ValueError(f"unknown discussion_mode {self.discussion_mode!r}") @dataclass diff --git a/deepwolf/i18n.py b/deepwolf/i18n.py index 870515a..6d6cdce 100644 --- a/deepwolf/i18n.py +++ b/deepwolf/i18n.py @@ -90,6 +90,14 @@ def pick(lang: str, en: str, zh: str) -> str: "en": "Day {day}: the village gathers to debate.", "zh": "第 {day} 天:村民聚集起来展开讨论。", }, + "speak_bid": { + "en": "{who} bids {priority}/10 for the floor.", + "zh": "{who} 出价 {priority}/10 争取发言。", + }, + "speak_bid_reasoned": { + "en": "{who} bids {priority}/10 for the floor — {reason}", + "zh": "{who} 出价 {priority}/10 争取发言 —— {reason}", + }, "vote_cast": { "en": "{voter} votes for {target}.", "zh": "{voter} 投票给 {target}。", diff --git a/deepwolf/llm/mock.py b/deepwolf/llm/mock.py index 3d0c8ab..32cf527 100644 --- a/deepwolf/llm/mock.py +++ b/deepwolf/llm/mock.py @@ -63,6 +63,8 @@ def complete(self, messages: list[dict[str, str]]) -> str: if kind == "witch": # The offline witch plays conservatively: it banks both potions. return json.dumps({"heal": False, "poison": None}) + if kind == "bid": + return json.dumps({"priority": self.rng.randint(0, 10), "reason": ""}) choice = self.rng.choice(candidates) if candidates else 0 return json.dumps({"choice": choice, "reasoning": "mock heuristic pick."}) diff --git a/deepwolf/prompts/templates.py b/deepwolf/prompts/templates.py index 5aaf831..7b9eea1 100644 --- a/deepwolf/prompts/templates.py +++ b/deepwolf/prompts/templates.py @@ -22,6 +22,7 @@ KIND_SPEAK = "speak" KIND_SHOOT = "shoot" KIND_WITCH = "witch" +KIND_BID = "bid" # Per-decision instructions, English / Chinese. @@ -161,6 +162,35 @@ def witch_request( return "\n\n".join(p for p in parts if p) +def bid_request(view: PlayerView) -> str: + """The user message asking an agent to bid for the discussion floor.""" + lang = view.lang + parts = [ + pick(lang, f"=== Day {view.day} ===", f"=== 第 {view.day} 天 ==="), + _players_block(view), + _secret_block(view), + _log_block(view), + pick( + lang, + "The village is about to debate. Bid for the discussion floor: how " + "urgently do you need to speak this round? Bid high only if you have " + "something genuinely important to say — the bid and your reason are " + "public.", + "村庄即将展开讨论。为发言权竞价:本轮你有多迫切需要发言?" + "只有当你确有重要的话要说时才出高价——你的出价和理由都会公开。", + ), + pick( + lang, + 'Respond with ONLY a JSON object: {"priority": , ' + '"reason": ""}. No other text.', + '只回复一个 JSON 对象:{"priority": <0-10 的整数>, ' + '"reason": "<简短的公开理由>"}。不要输出其他内容。', + ), + f"[[ACTION kind={KIND_BID} candidates= lang={lang}]]", + ] + return "\n\n".join(p for p in parts if p) + + def _players_block(view: PlayerView) -> str: lang = view.lang alive = pick(lang, "alive", "存活") diff --git a/tests/test_bidding.py b/tests/test_bidding.py new file mode 100644 index 0000000..f1922ce --- /dev/null +++ b/tests/test_bidding.py @@ -0,0 +1,77 @@ +"""Tests for bidding-based discussion turn-taking.""" + +from __future__ import annotations + +import pytest + +from deepwolf.agents.llm_agent import LLMAgent +from deepwolf.agents.random_agent import RandomAgent +from deepwolf.game.engine import GameEngine +from deepwolf.game.events import EventType +from deepwolf.game.roles import Faction, standard_setup +from deepwolf.game.state import GameConfig +from deepwolf.llm.mock import MockProvider + + +def test_discussion_mode_defaults_to_ordered(): + assert GameConfig.standard(7).discussion_mode == "ordered" + + +def test_unknown_discussion_mode_is_rejected(): + with pytest.raises(ValueError): + GameConfig(roles=standard_setup(7), discussion_mode="freeforall") + + +def test_ordered_mode_emits_no_bids(): + config = GameConfig.standard(7, seed=1) # ordered is the default + result = GameEngine(config, lambda pid, _: RandomAgent(pid)).run() + assert not any(e.type is EventType.SPEAK_BID for e in result.events) + + +def test_bidding_mode_emits_one_bid_per_speaker(): + config = GameConfig.standard(7, seed=2, discussion_mode="bidding") + result = GameEngine(config, lambda pid, _: RandomAgent(pid)).run() + + day1_bids = [e for e in result.events if e.type is EventType.SPEAK_BID and e.day == 1] + day1_talk = [e for e in result.events if e.type is EventType.STATEMENT and e.day == 1] + assert day1_bids and len(day1_bids) == len(day1_talk) + for bid in day1_bids: + assert 0 <= bid.data["priority"] <= 10 + + +class _BidScript(RandomAgent): + """Bids its own player id as priority — higher seat speaks earlier.""" + + def bid(self, view): + return (view.me_id, f"P{view.me_id}") + + +def test_bidding_seats_the_highest_bidder_first(): + config = GameConfig.standard(7, seed=1, discussion_mode="bidding") + result = GameEngine(config, lambda pid, _: _BidScript(pid)).run() + speakers = [ + e.actor for e in result.events + if e.type is EventType.STATEMENT and e.day == 1 + ] + # priority == player id, so statements run in descending id order + assert speakers == sorted(speakers, reverse=True) + + +def test_bidding_game_with_llm_agents_reaches_a_winner(): + config = GameConfig.standard(7, seed=3, discussion_mode="bidding") + provider = MockProvider(seed=0) + result = GameEngine(config, lambda pid, _: LLMAgent(pid, provider)).run() + assert result.winner in (Faction.VILLAGE, Faction.WEREWOLVES) + assert any(e.type is EventType.SPEAK_BID for e in result.events) + + +def test_illegal_bids_are_clamped(): + class _RogueBidder(RandomAgent): + def bid(self, view): + return (999, "x" * 999) # out-of-range priority, over-long reason + + config = GameConfig.standard(6, seed=1, discussion_mode="bidding") + result = GameEngine(config, lambda pid, _: _RogueBidder(pid)).run() + for bid in (e for e in result.events if e.type is EventType.SPEAK_BID): + assert 0 <= bid.data["priority"] <= 10 + assert len(bid.data["reason"]) <= 200