Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down
9 changes: 9 additions & 0 deletions deepwolf/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions deepwolf/agents/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
3 changes: 3 additions & 0 deletions deepwolf/agents/random_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
22 changes: 22 additions & 0 deletions deepwolf/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
39 changes: 38 additions & 1 deletion deepwolf/game/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions deepwolf/game/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions deepwolf/game/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions deepwolf/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}。",
Expand Down
2 changes: 2 additions & 0 deletions deepwolf/llm/mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."})
Expand Down
30 changes: 30 additions & 0 deletions deepwolf/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
KIND_SPEAK = "speak"
KIND_SHOOT = "shoot"
KIND_WITCH = "witch"
KIND_BID = "bid"


# Per-decision instructions, English / Chinese.
Expand Down Expand Up @@ -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": <integer 0-10>, '
'"reason": "<short public 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", "存活")
Expand Down
77 changes: 77 additions & 0 deletions tests/test_bidding.py
Original file line number Diff line number Diff line change
@@ -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
Loading