Skip to content

Commit 26c7ec6

Browse files
JuneQQQJuneQQQclaude
authored
Add the Witch role (#10)
The Witch is a villager with two one-time potions. Each night she learns who the werewolves attacked and may use a healing potion to save them and/or a poison potion to kill any one player. - roles.py: new Role.WITCH; standard_setup seats one once the village has five or more seats. - engine.py: _witch_action runs the Witch's turn after the doctor; the night phase is reworked to resolve multiple simultaneous deaths (_announce_deaths), each of which can still trigger a Hunter shot. Potion use is tracked on GameState so each potion is strictly once-per-game. - agents: new concrete Agent.witch_turn; RandomAgent and LLMAgent override it, CLI HumanAgent prompts for it. The offline mock banks both potions. - prompts: a dedicated witch_request template. - 3 new tests covering the heal and poison potions. Closes #2 Co-authored-by: JuneQQQ <june1243134432@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent edc1663 commit 26c7ec6

13 files changed

Lines changed: 263 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@ All notable changes to this project are documented here. The format follows
1515
— the `deepwolf.game.transcript` module and a `--transcript PATH` flag on
1616
`deepwolf simulate` write a finished game as a versioned, machine-readable
1717
JSON record (players, full event log, winner).
18+
- **Witch role** ([#2](https://github.com/JuneQQQ/deepwolf/issues/2)) — a
19+
villager with two one-time potions. Each night the Witch learns who the
20+
werewolves attacked and may spend a healing potion to save them and/or a
21+
poison potion to kill any player. The night phase now resolves multiple
22+
simultaneous deaths.
1823

1924
### Planned
20-
- Additional roles: Witch, Cupid.
25+
- Additional roles: Cupid.
2126
- A model leaderboard built from arena runs.
2227

2328
## [0.1.0] — 2026-05-18

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ to a human as a copilot.
3232
## Features
3333

3434
- ♟️ **A strict, seeded rules engine.** Night/day cycle with Werewolf, Seer,
35-
Doctor and Hunter abilities. Every game is reproducible from a seed; an
35+
Doctor, Hunter and Witch abilities. Every game is reproducible from a seed; an
3636
illegal or hallucinated agent move can never corrupt a game.
3737
- 🔌 **Vendor-neutral LLMs.** Any OpenAI-compatible endpoint — OpenAI,
3838
DeepSeek, Xiaomi MiMo, Groq, OpenRouter, a local server. Change one env var.
@@ -151,7 +151,7 @@ a natural-language read of the *statements* the heuristic ignores.
151151
## Roadmap
152152

153153
See [CHANGELOG.md](CHANGELOG.md) and the [issue tracker](https://github.com/JuneQQQ/deepwolf/issues).
154-
Near-term: more roles (Witch, Cupid) and a leaderboard of models in the arena.
154+
Near-term: more roles (Cupid) and a leaderboard of models in the arena.
155155

156156
## Contributing
157157

deepwolf/agents/base.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,19 @@ def dying_shot(self, view: PlayerView) -> int:
4848
"""
4949
pool = view.others_alive() or list(view.living_ids)
5050
return view.rng.choice(pool)
51+
52+
def witch_turn(
53+
self,
54+
view: PlayerView,
55+
victim: int | None,
56+
can_heal: bool,
57+
can_poison: bool,
58+
) -> tuple[bool, int | None]:
59+
"""Return ``(use_heal, poison_target)`` for the Witch's night.
60+
61+
``victim`` is who the werewolves attacked (``None`` if they did not, or
62+
the Witch cannot know). ``can_heal`` / ``can_poison`` say which one-time
63+
potions are still available. Only called for a living Witch. Concrete,
64+
so existing agents need no change; the default uses no potions.
65+
"""
66+
return (False, None)

deepwolf/agents/llm_agent.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ def dying_shot(self, view: PlayerView) -> int:
6060
candidates = view.others_alive() or list(view.living_ids)
6161
return self._decide(view, T.KIND_SHOOT, candidates)
6262

63+
def witch_turn(
64+
self, view: PlayerView, victim: int | None, can_heal: bool, can_poison: bool
65+
) -> tuple[bool, int | None]:
66+
messages = [
67+
{"role": "system", "content": T.system_message(view)},
68+
{"role": "user", "content": T.witch_request(view, victim, can_heal, can_poison)},
69+
]
70+
data = _parse_json(self._call(messages))
71+
if not data:
72+
return (False, None)
73+
heal = bool(data.get("heal")) and can_heal
74+
poison = data.get("poison")
75+
poison_target = poison if (isinstance(poison, int) and can_poison) else None
76+
return (heal, poison_target)
77+
6378
# ------------------------------------------------------------- internals
6479
def _decide(self, view: PlayerView, kind: str, candidates: list[int]) -> int:
6580
messages = self._messages(view, kind, candidates)

deepwolf/agents/random_agent.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ def vote(self, view: PlayerView) -> int:
3333
def speak(self, view: PlayerView) -> str:
3434
return view.rng.choice(_FILLER)
3535

36+
def witch_turn(
37+
self, view: PlayerView, victim: int | None, can_heal: bool, can_poison: bool
38+
) -> tuple[bool, int | None]:
39+
heal = can_heal and view.rng.random() < 0.5
40+
poison: int | None = None
41+
if can_poison and view.rng.random() < 0.3 and view.others_alive():
42+
poison = view.rng.choice(view.others_alive())
43+
return (heal, poison)
44+
3645
@staticmethod
3746
def _pool(view: PlayerView) -> list[int]:
3847
return view.others_alive() or list(view.living_ids)

deepwolf/cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,28 @@ def dying_shot(self, view: PlayerView) -> int:
123123
pool = view.others_alive() or list(view.living_ids)
124124
return self._ask(view, pool, "You are the dying Hunter — who do you shoot?")
125125

126+
def witch_turn(
127+
self, view: PlayerView, victim: int | None, can_heal: bool, can_poison: bool
128+
) -> tuple[bool, int | None]:
129+
self._banner(view, "WITCH")
130+
if victim is not None:
131+
self.console.print(f" The werewolves attacked {view.name(victim)} (P{victim}).")
132+
else:
133+
self.console.print(" You sense no werewolf attack you could counter.")
134+
heal = False
135+
if can_heal and victim is not None:
136+
answer = self.console.input(f" Use your HEALING potion on P{victim}? [y/N] ")
137+
heal = answer.strip().lower().startswith("y")
138+
poison: int | None = None
139+
if can_poison:
140+
answer = self.console.input(
141+
" POISON potion — enter a player id to kill, or blank to skip: "
142+
)
143+
raw = answer.strip().lstrip("Pp")
144+
if raw.isdigit() and int(raw) in view.others_alive():
145+
poison = int(raw)
146+
return (heal, poison)
147+
126148
def _banner(self, view: PlayerView, phase: str) -> None:
127149
self.console.rule(f"You are {view.me_name} (P{view.me_id}) — {view.me_role.value}{phase}")
128150
for note in view.private_notes:

deepwolf/game/engine.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,24 +109,40 @@ def _night_phase(self) -> None:
109109
victim = self._werewolf_target()
110110
self._seer_inspection()
111111
protected = self._doctor_protection()
112+
healed, poisoned = self._witch_action(victim)
112113

113-
if victim is not None and victim != protected:
114-
self._kill(victim, "killed by werewolves")
115-
reveal = self._role_reveal(victim)
116-
self._emit(Event(
117-
EventType.DEATH_ANNOUNCED, s.day, "night",
118-
f"At dawn the village finds {s.name(victim)} (P{victim}) dead."
119-
+ reveal[0],
120-
target=victim, data=reveal[1],
121-
))
122-
self._process_hunter(victim)
123-
else:
124-
saved = protected is not None and protected == victim
114+
# Collect the night's deaths, then announce them together.
115+
deaths: list[tuple[int, str]] = []
116+
if victim is not None and victim != protected and not healed:
117+
deaths.append((victim, "killed by werewolves"))
118+
if poisoned is not None and poisoned not in {d[0] for d in deaths}:
119+
deaths.append((poisoned, "poisoned by the Witch"))
120+
121+
if not deaths:
122+
saved = victim is not None and (victim == protected or healed)
125123
self._emit(Event(
126124
EventType.QUIET_NIGHT, s.day, "night",
127125
"The village wakes to find everyone alive."
128-
+ (" The doctor's vigil paid off." if saved else ""),
126+
+ (" Someone was watched over in the dark." if saved else ""),
127+
))
128+
return
129+
self._announce_deaths(deaths)
130+
131+
def _announce_deaths(self, deaths: list[tuple[int, str]]) -> None:
132+
"""Mark each night death, announce it, and fire any Hunter shots."""
133+
s = self.state
134+
for pid, cause in deaths:
135+
if not s.player(pid).alive:
136+
continue # already taken by an earlier death this night
137+
self._kill(pid, cause)
138+
reveal = self._role_reveal(pid)
139+
self._emit(Event(
140+
EventType.DEATH_ANNOUNCED, s.day, "night",
141+
f"At dawn the village finds {s.name(pid)} (P{pid}) dead."
142+
+ reveal[0],
143+
target=pid, data=reveal[1],
129144
))
145+
self._process_hunter(pid)
130146

131147
def _werewolf_target(self) -> int | None:
132148
s = self.state
@@ -178,6 +194,59 @@ def _doctor_protection(self) -> int | None:
178194
))
179195
return target
180196

197+
def _witch_action(self, victim: int | None) -> tuple[bool, int | None]:
198+
"""Run the Witch's night, returning (victim healed?, poison target)."""
199+
s = self.state
200+
witches = s.living_with_role(Role.WITCH)
201+
if not witches:
202+
return False, None
203+
witch = witches[0]
204+
can_heal = not s.witch_heal_used and victim is not None
205+
can_poison = not s.witch_poison_used
206+
if not (can_heal or can_poison):
207+
return False, None
208+
209+
if victim is not None:
210+
self._emit(Event(
211+
EventType.WITCH_NIGHT_INFO, s.day, "night",
212+
f"The werewolves attacked {s.name(victim)} (P{victim}) tonight.",
213+
target=victim, public=False, visible_to=frozenset({witch.id}),
214+
))
215+
view = build_view(s, witch.id, Phase.NIGHT)
216+
try:
217+
heal, poison = self.agents[witch.id].witch_turn(
218+
view, victim, can_heal, can_poison
219+
)
220+
except Exception: # noqa: BLE001 - an agent error must not crash a game
221+
heal, poison = False, None
222+
223+
healed = False
224+
if heal and can_heal and victim is not None:
225+
s.witch_heal_used = True
226+
healed = True
227+
self._emit(Event(
228+
EventType.WITCH_POTION, s.day, "night",
229+
f"You use your healing potion on {s.name(victim)} (P{victim}).",
230+
target=victim, public=False, visible_to=frozenset({witch.id}),
231+
data={"potion": "heal"},
232+
))
233+
poison_target: int | None = None
234+
if (
235+
can_poison
236+
and isinstance(poison, int)
237+
and poison in s.living_ids()
238+
and poison != witch.id
239+
):
240+
s.witch_poison_used = True
241+
poison_target = poison
242+
self._emit(Event(
243+
EventType.WITCH_POTION, s.day, "night",
244+
f"You use your poison potion on {s.name(poison)} (P{poison}).",
245+
target=poison, public=False, visible_to=frozenset({witch.id}),
246+
data={"potion": "poison"},
247+
))
248+
return healed, poison_target
249+
181250
# ----------------------------------------------------------------- day
182251
def _day_phase(self) -> None:
183252
s = self.state

deepwolf/game/events.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ class EventType(str, Enum):
2020
WEREWOLF_TARGET = "werewolf_target" # private: a wolf names a victim
2121
SEER_RESULT = "seer_result" # private: the seer's inspection
2222
DOCTOR_PROTECT = "doctor_protect" # private: the doctor's choice
23+
WITCH_NIGHT_INFO = "witch_night_info" # private: who the wolves attacked
24+
WITCH_POTION = "witch_potion" # private: a potion the witch used
2325
DAY_BREAKS = "day_breaks" # public
2426
DEATH_ANNOUNCED = "death_announced" # public
2527
QUIET_NIGHT = "quiet_night" # public: nobody died

deepwolf/game/roles.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class Role(str, Enum):
2929
SEER = "seer"
3030
DOCTOR = "doctor"
3131
HUNTER = "hunter"
32+
WITCH = "witch"
3233

3334
@property
3435
def faction(self) -> Faction:
@@ -37,7 +38,7 @@ def faction(self) -> Faction:
3738
@property
3839
def has_night_action(self) -> bool:
3940
"""Whether the engine must consult this role during the night phase."""
40-
return self in (Role.WEREWOLF, Role.SEER, Role.DOCTOR)
41+
return self in (Role.WEREWOLF, Role.SEER, Role.DOCTOR, Role.WITCH)
4142

4243
@property
4344
def summary(self) -> str:
@@ -66,6 +67,12 @@ def summary(self) -> str:
6667
"killed at night — you take one living player down with you. You "
6768
"win with the village."
6869
),
70+
Role.WITCH: (
71+
"You hold two one-time potions. Each night you learn who the "
72+
"werewolves attacked; you may use a healing potion to save them, "
73+
"and/or a poison potion to kill any one player. You win with the "
74+
"village."
75+
),
6976
}
7077

7178

@@ -89,6 +96,8 @@ def standard_setup(n_players: int) -> list[Role]:
8996
roles.append(Role.DOCTOR)
9097
if village_seats >= 4:
9198
roles.append(Role.HUNTER)
99+
if village_seats >= 5:
100+
roles.append(Role.WITCH)
92101

93102
roles += [Role.VILLAGER] * (n_players - len(roles))
94103
return roles

deepwolf/game/state.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class GameState:
7474
day: int = 0
7575
phase: Phase = Phase.SETUP
7676
winner: Faction | None = None
77+
witch_heal_used: bool = False
78+
witch_poison_used: bool = False
7779

7880
# ---- construction -----------------------------------------------------
7981
@classmethod

0 commit comments

Comments
 (0)