Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)

This commit is contained in:
hhhge committed 2026-10-03 20:47:44 +08:00
commit 7f5ea6bee8
55 files changed
+6999

No files matched your search

View File
Whitespace-only changes.
+4
View File
@@ -0,0 +1,4 @@
from .agent import LLMAgent, make_agent
from .fallback import HeuristicAgent
__all__ = ["LLMAgent", "HeuristicAgent", "make_agent"]
+46
View File
@@ -0,0 +1,46 @@
"""AI 玩家代理:优先 LLM,失败自动降级到启发式。"""
from __future__ import annotations
from .fallback import HeuristicAgent
class LLMAgent:
def __init__(self, player) -> None:
self.player = player
self.fallback = HeuristicAgent(player)
from .llm_client import get_llm_client
self.client = get_llm_client()
async def decide(self, kind: str, ctx: dict):
try:
from .prompts import decision_messages
system, user = decision_messages(kind, ctx)
data = await self.client.chat_json(system, user)
target = data.get("target")
if target is None:
return None
target = int(target)
if ctx["options"] and target not in ctx["options"]:
return await self.fallback.decide(kind, ctx)
return target
except Exception:
return await self.fallback.decide(kind, ctx)
async def speak(self, ctx: dict) -> str:
try:
from .prompts import speech_messages
system, user = speech_messages(ctx)
text = await self.client.chat_text(system, user, timeout=40.0)
text = text.strip().strip('"').strip()
if not text:
return await self.fallback.speak(ctx)
return text[:300]
except Exception:
return await self.fallback.speak(ctx)
def make_agent(player):
from ...config import CONFIG
if CONFIG.llm.configured:
return LLMAgent(player)
return HeuristicAgent(player)
+127
View File
@@ -0,0 +1,127 @@
"""启发式兜底 AI:无 LLM 配置(或 LLM 调用失败)时保证游戏可玩。"""
from __future__ import annotations
import random
class HeuristicAgent:
def __init__(self, player) -> None:
self.player = player
# ------------------------------------------------------------------
async def decide(self, kind: str, ctx: dict):
handler = getattr(self, f"_decide_{kind}", None)
if handler is None:
return None
return handler(ctx)
async def speak(self, ctx: dict) -> str:
return self._speak(ctx)
# ------------------------------------------------------------------ 内部
def _alive_others(self, ctx: dict) -> list[int]:
me = ctx["seat"]
return [p["seat"] for p in ctx["alive"] if p["seat"] != me]
def _claim_targets(self, ctx: dict) -> list[int]:
claimed = [s for s in ctx.get("claimed_seers", [])]
me = self.player
mates = set(ctx.get("teammates", []))
return [s for s in claimed
if s != me.seat and s not in mates]
# ------------------------------------------------------------------ 决策
def _decide_kill(self, ctx: dict):
victims = [o for o in ctx["options"]]
claims = self._claim_targets(ctx)
pool = [s for s in claims if s in victims]
if pool:
return random.choice(pool)
return random.choice(victims) if victims else None
def _decide_protect(self, ctx: dict):
claims = [s for s in self._claim_targets(ctx) if s in ctx["options"]]
if claims:
return random.choice(claims)
return random.choice(ctx["options"]) if ctx["options"] else None
def _decide_check(self, ctx: dict):
return random.choice(ctx["options"]) if ctx["options"] else None
def _decide_witch_save(self, ctx: dict):
target = ctx["options"][0] if ctx["options"] else None
if target is None:
return None
if target == ctx["seat"]:
return target # 首夜自救
if target in ctx.get("claimed_seers", []):
return target
return None # 留药
def _decide_witch_poison(self, ctx: dict):
return None # 保守:不毒
def _decide_shoot(self, ctx: dict):
return random.choice(ctx["options"]) if ctx["options"] else None
def _decide_elect(self, ctx: dict):
if ctx.get("role") == "seer":
return 1
if ctx.get("role") == "werewolf":
return 1 if random.random() < 0.3 else None
return 1 if random.random() < 0.15 else None
def _decide_sheriff_vote(self, ctx: dict):
return random.choice(ctx["options"]) if ctx["options"] else None
def _decide_badge(self, ctx: dict):
claims = [s for s in ctx.get("claimed_seers", []) if s in ctx["options"]]
if claims:
return random.choice(claims)
return random.choice(ctx["options"]) if ctx["options"] else None
def _decide_vote(self, ctx: dict):
claims = [s for s in self._claim_targets(ctx) if s in ctx["options"]]
if claims and ctx.get("role") != "werewolf":
# 好人跟跳预言家的对跳/划水者走:简单起见投被多疑的目标
pass
pool = [o for o in ctx["options"] if o != ctx["seat"]]
if not pool:
return None
if ctx.get("role") == "werewolf":
mates = set(ctx.get("teammates", []))
pool = [o for o in pool if o not in mates] or pool
return random.choice(pool)
# ------------------------------------------------------------------ 发言
def _speak(self, ctx: dict) -> str:
role = ctx.get("role")
day = ctx.get("day", 1)
purpose = ctx.get("purpose")
seat = ctx["seat"]
alive = [p["seat"] for p in ctx["alive"] if p["seat"] != seat]
pick = random.choice(alive) if alive else None
if purpose == "election":
if role == "seer":
return "我是预言家,想拿警徽带队报查验,请大家支持我。"
return f"我{seat}号想为团队做点贡献,上警听听大家的发言。"
if role == "seer":
results = ";".join(
f"{r['seat']}号是{r['result']}" for r in ctx.get("seer_results", [])[-2:]) or "暂时没有信息"
return f"我是预言家,我的查验:{results}。请好人记住我的票。"
if role == "werewolf":
lines = [
f"我是个好人,昨天{pick}号的发言我觉得有点飘,大家可以听听。",
"我没有太多信息,先听后置位怎么说。",
f"这局信息很少,我倾向于{pick}号有问题,先投为敬。",
]
return random.choice(lines)
if role == "witch":
return "我的身份信息暂时不方便透露,从发言上看我觉得节奏有点乱,大家稳住。"
if role == "hunter":
return f"我是普通村民视角,{pick}号的发言我听不太懂,保留怀疑。"
if role == "guard":
return "我从守卫……呃,从好人视角看,这局要小心悍跳,先听大家的。"
if day == 1:
return f"我是村民,没有什么信息,{pick}号的发言给我感觉还行,先观察。"
return f"听到现在,我觉得{pick}号的发言前后有点矛盾,今天我可能跟票他。"
+75
View File
@@ -0,0 +1,75 @@
"""OpenAI 兼容异步客户端:JSON 决策与自由文本发言,自动降级重试。"""
from __future__ import annotations
import asyncio
import json
import re
from ...config import CONFIG, LLMConfig
def parse_json_obj(text: str) -> dict | None:
if not text:
return None
text = text.strip()
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S)
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
return None
try:
data = json.loads(text[start:end + 1])
return data if isinstance(data, dict) else None
except json.JSONDecodeError:
return None
class LLMClient:
def __init__(self, cfg: LLMConfig) -> None:
from openai import AsyncOpenAI
self.model = cfg.model
self.temperature = cfg.temperature
self._client = AsyncOpenAI(
base_url=cfg.base_url, api_key=cfg.api_key, timeout=30, max_retries=0)
async def _create(self, system: str, user: str, *, json_mode: bool, timeout: float) -> str:
kwargs: dict = {
"model": self.model,
"temperature": self.temperature,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"timeout": timeout,
}
if json_mode:
kwargs["response_format"] = {"type": "json_object"}
try:
resp = await self._client.chat.completions.create(**kwargs)
return resp.choices[0].message.content or ""
except Exception:
kwargs.pop("response_format", None) # 部分兼容端点不支持 response_format
resp = await self._client.chat.completions.create(**kwargs)
return resp.choices[0].message.content or ""
async def chat_json(self, system: str, user: str, *, timeout: float = 22.0) -> dict:
last = ""
for _ in range(2):
text = await self._create(system, user, json_mode=True, timeout=timeout)
data = parse_json_obj(text)
if data is not None:
return data
last = text
raise ValueError(f"LLM 未返回有效 JSON: {last[:120]}")
async def chat_text(self, system: str, user: str, *, timeout: float = 40.0) -> str:
return (await self._create(system, user, json_mode=False, timeout=timeout)).strip()
_client: LLMClient | None = None
def get_llm_client() -> LLMClient:
global _client
if _client is None:
_client = LLMClient(CONFIG.llm)
return _client
+87
View File
@@ -0,0 +1,87 @@
"""AI 玩家提示词:身份人设、决策 JSON、公开发言。"""
from __future__ import annotations
import json
RULES = """【狼人杀规则简述】
- 夜晚顺序:守卫守护 → 狼人袭击 → 预言家查验 → 女巫用药(解药/毒药各一瓶,每夜限一瓶,首夜可自救)。
- 守卫不能连续两晚守同一人;「同守同救」(守卫和女巫同时保护被袭者)会导致该玩家死亡。
- 白天:警长竞选(首日)→ 轮流发言 → 全员投票放逐(平票无人出局,警长票计1.5票)。
- 猎人被投票或被狼人杀死时可开枪带走一人(被毒杀不能开枪)。
- 胜负:狼人杀光所有神职或所有村民(屠边)即获胜;好人放逐所有狼人则获胜。"""
KIND_QUESTIONS = {
"kill": ("作为狼人,选择今晚袭击的目标",
"通常优先袭击跳预言家的玩家、发言逻辑威胁大的玩家;也要考虑票型,避开会被怀疑的队友策略。"),
"protect": ("作为守卫,选择今晚守护的玩家",
"通常守护跳预言家的玩家或你认为重要的神职;也可以空守。"),
"check": ("作为预言家,选择今晚查验的玩家",
"优先验身份模糊、立场可疑的玩家,积累信息。"),
"witch_save": ("作为女巫,决定是否使用解药救人",
"如果被袭者是跳预言家的关键角色或你自己(首夜),通常值得救;也要提防狼人骗药。"),
"witch_poison": ("作为女巫,决定是否使用毒药毒杀一人",
"只有在有较强把握某人是狼时才用毒,误毒好人会严重伤害好人阵营;可以不用。"),
"shoot": ("作为猎人,选择开枪带走的目标",
"带走你认为最像狼的玩家;没把握也可以不开枪。"),
"elect": ("决定是否上警竞选警长",
"跳预言家通常应该上警来报查验;狼人可以选择不上警隐藏身份或悍跳预言家。"),
"sheriff_vote": ("投票选出警长", "把票投给你认为能带领好人、发言可信的候选人。"),
"badge": ("作为死亡的警长,决定警徽流向",
"通常把警徽交给最信任的好人(比如你验过的好人);也可以撕毁警徽表达态度。"),
"vote": ("投票放逐一名玩家", "根据发言、逻辑和票型投出你最怀疑的狼人;没把握可以弃票。"),
}
def _state_text(ctx: dict) -> str:
alive = "、".join(f"{p['seat']}号{p['name']}" for p in ctx["alive"])
priv = "\n".join(ctx["private"]) if ctx["private"] else "无"
return (
f"【当前局面】第{ctx['day']}天,{ctx['phase_text']}\n"
f"【存活玩家】{alive}\n"
f"【你的私有信息】\n{priv}\n"
f"【对局记录(公开部分,最近在前为旧)】\n{ctx['log_text'] or '(暂无)'}"
)
def decision_messages(kind: str, ctx: dict) -> tuple[str, str]:
title, tip = KIND_QUESTIONS.get(kind, (kind, ""))
options = ctx["options"]
opts_text = "、".join(str(o) + "号" for o in options) if options else "无"
allow_skip = kind in ("witch_save", "witch_poison", "shoot", "elect", "badge", "vote", "protect", "kill")
system = (
f"你正在玩一局狼人杀。你的名字是「{ctx['name']}」,座位号是{ctx['seat']}号。\n"
f"你的身份:{ctx['role_name']} —— {ctx['role_desc']}\n"
f"你的性格:{ctx['persona'] or '普通玩家'}\n"
f"{RULES}\n"
"你是一个真实的人类玩家。永远不要提及你是AI、模型或程序。"
)
user = (
f"{_state_text(ctx)}\n\n"
f"【你的决策】{title}。\n{tip}\n"
f"可选目标(座位号):{opts_text}\n"
+ ("你可以选择放弃(target 为 null)。\n" if allow_skip else "必须选择一个目标。\n")
+ '只输出 JSON:{"target": 座位号数字或null, "reason": "不超过30字的理由"}'
)
return system, user
def speech_messages(ctx: dict) -> tuple[str, str]:
purpose = ctx.get("purpose") or "day"
if purpose == "election":
task = "现在是警长竞选阶段,请发表你的竞选发言(表决心、报身份或藏身份,符合你的策略)。"
else:
task = "轮到你发言了。请分析当前局势,表达你的身份立场、怀疑对象和投票意向。"
system = (
f"你正在玩一局狼人杀。你的名字是「{ctx['name']}」,座位号是{ctx['seat']}号。\n"
f"你的身份:{ctx['role_name']} —— {ctx['role_desc']}\n"
f"你的性格:{ctx['persona'] or '普通玩家'}\n"
f"{RULES}\n"
"你是一个真实的人类玩家。永远不要提及你是AI、模型或程序。"
"发言要口语化、简短自然,像真人打字,可以引用别人的发言和票型,不要罗列条目。"
)
user = f"{_state_text(ctx)}\n\n【任务】{task}\n直接输出发言内容本身(2~5句话),不要任何前缀、引号或解释。"
return system, user
def options_json(options: list) -> str:
return json.dumps(options, ensure_ascii=False)
+40
View File
@@ -0,0 +1,40 @@
"""板子(角色配置)预设与校验。"""
from __future__ import annotations
from dataclasses import dataclass, field
from .roles import Role
@dataclass(frozen=True)
class Board:
key: str
name: str
roles: list[Role] = field(default_factory=list)
@property
def seat_count(self) -> int:
return len(self.roles)
BOARDS: dict[str, Board] = {
"classic12": Board(
"classic12", "经典12人局(4狼 4民 预女猎守)",
[Role.WEREWOLF] * 4 + [Role.VILLAGER] * 4
+ [Role.SEER, Role.WITCH, Role.HUNTER, Role.GUARD],
),
"standard10": Board(
"standard10", "标准10人局(3狼 3民 预女猎守)",
[Role.WEREWOLF] * 3 + [Role.VILLAGER] * 3
+ [Role.SEER, Role.WITCH, Role.HUNTER, Role.GUARD],
),
"quick9": Board(
"quick9", "快节奏9人局(3狼 3民 预女猎)",
[Role.WEREWOLF] * 3 + [Role.VILLAGER] * 3
+ [Role.SEER, Role.WITCH, Role.HUNTER],
),
}
def get_board(key: str) -> Board:
return BOARDS.get(key, BOARDS["classic12"])
+785
View File
@@ -0,0 +1,785 @@
"""游戏引擎:昼夜状态机、技能结算、警长竞选、胜负判定。
引擎通过 Room 与客户端通信;AI 玩家的决策交给 agent(LLM 或启发式兜底)。
所有等待玩家操作的实现方式:注册一个 Pending(含每个席位的 Future),
人类玩家的提交通过 Room.handle_message 落到 handle_action / handle_speech,
AI 玩家则由后台任务调用 agent 后写入同一个 Future,超时按默认值处理。
"""
from __future__ import annotations
import asyncio
import random
import time
from .player import Player
from .roles import ROLE_INFO, Role
from .board import Board
class Pending:
"""一次「等待若干席位提交操作」的登记。"""
def __init__(self, key: str, seats: list[int], options: list[int] | None, text: str, *,
allow_skip: bool = False, input_text: bool = False,
first_wins: bool = False, purpose: str = "") -> None:
self.key = key
self.seats = list(seats)
self.options = options
self.text = text
self.allow_skip = allow_skip
self.input_text = input_text
self.first_wins = first_wins # 狼队刀口:第一个提交者生效
self.purpose = purpose # speech 用:day / election
self.futures: dict[int, asyncio.Future] = {}
self.shared: asyncio.Future | None = None
if first_wins:
self.shared = asyncio.get_running_loop().create_future()
self.deadline = 0.0 # loop.time()
self.resolved = False
def seconds_left(self) -> int:
return max(0, int(self.deadline - time.monotonic()))
class Engine:
def __init__(self, room, board: Board, settings: dict) -> None:
self.room = room
self.board = board
self.settings = settings
self.running = True
self.reveal = False
self.day = 1
self.phase = "role_reveal"
self.phase_text = "身份分发中"
self.winner: str | None = None
self.winner_text: str | None = None
self.log: list[dict] = [] # {text, visible(None=公开|set[seat]), tag}
self.sheriff_seat: int | None = None
self.candidates: list[int] = []
self.claimed_seers: set[int] = set()
self.seer_results: dict[int, list[dict]] = {}
self.potions: dict[int, dict] = {}
self.guard_last: int | None = None
self.current_speaker: int | None = None
self.last_votes: list[dict] = []
self.pending_night_deaths: list[int] = []
self.night_death_causes: dict[int, str] = {}
self._pending: dict[str, Pending] = {}
self._agents: dict[int, object] = {}
# ================================================================ 工具
def alive_players(self) -> list[Player]:
return [self.room.players[s] for s in sorted(self.room.players) if self.room.players[s].alive]
def alive_seats(self) -> list[int]:
return [p.seat for p in self.alive_players()]
def player(self, seat: int) -> Player:
return self.room.players[seat]
def _alive_role(self, role: Role) -> Player | None:
for p in self.alive_players():
if p.role == role:
return p
return None
def _log(self, text: str, seat: int | None = None, tag: str = "system") -> None:
self.log.append({
"text": text,
"visible": None if seat is None else {seat},
"tag": tag,
})
def _log_public(self, text: str, tag: str = "system") -> None:
self._log(text, None, tag)
def _log_for(self, seats: set[int], text: str, tag: str = "system") -> None:
self.log.append({"text": text, "visible": set(seats), "tag": tag})
def _timeout(self, kind: str) -> int:
key = {"night": "night_seconds", "speech": "speech_seconds", "vote": "vote_seconds"}.get(kind, "vote_seconds")
return int(self.settings.get(key, 30))
def _agent(self, player: Player):
if player.seat not in self._agents:
from .ai import make_agent
self._agents[player.seat] = make_agent(player)
return self._agents[player.seat]
def is_candidate(self, seat: int) -> bool:
return seat in self.candidates
def visible_role_for(self, viewer_seat: int, target: Player) -> tuple[str | None, str | None]:
"""viewer 视角下 target 的角色是否可见(狼队友互见)。"""
viewer = self.room.players.get(viewer_seat)
if viewer and viewer.role == Role.WEREWOLF and target.role == Role.WEREWOLF:
return target.role.value, ROLE_INFO[target.role].name
return None, None
# ================================================================ 主流程
async def run(self) -> None:
try:
self._assign_roles()
await self._set_phase("role_reveal", "身份分发")
self._send_role_logs()
await asyncio.sleep(1.5)
while not self.winner:
await self._night()
if self._check_win():
break
await self._dawn()
if self._check_win():
break
if self.day == 1:
await self._sheriff_election()
await self._day_speeches()
await self._day_vote()
if self._check_win():
break
self.day += 1
except asyncio.CancelledError:
raise
except Exception as exc: # 防止引擎任务静默死亡
self._log_public(f"游戏异常终止:{exc!r}")
self.winner = self.winner or "good"
self.winner_text = self.winner_text or "游戏异常结束"
finally:
self.running = False
self.reveal = True
self.phase = "game_over"
self.phase_text = "游戏结束"
self.current_speaker = None
self.candidates = []
self._pending.clear()
for p in self.room.players.values():
p.speaking = False
await self.room.broadcast_snapshot()
def _assign_roles(self) -> None:
roles = list(self.board.roles)
random.shuffle(roles)
for seat in sorted(self.room.players):
p = self.room.players[seat]
p.reset_for_new_game(roles.pop())
if p.role == Role.SEER:
self.seer_results[seat] = []
if p.role == Role.WITCH:
self.potions[seat] = {"antidote": True, "poison": True}
def _send_role_logs(self) -> None:
wolves = sorted(p.seat for p in self.room.players.values() if p.role == Role.WEREWOLF)
for p in self.room.players.values():
info = ROLE_INFO[p.role]
line = f"【你的身份】{info.emoji} {info.name}({p.seat}号)\n{info.description}"
if p.role == Role.WEREWOLF:
mates = [s for s in wolves if s != p.seat]
line += "\n你的狼队友:" + ("、".join(f"{s}号" for s in mates) if mates else "无(独狼)")
self._log(line, seat=p.seat, tag="identity")
def _check_win(self) -> bool:
alive = self.alive_players()
wolves = [p for p in alive if p.role == Role.WEREWOLF]
gods = [p for p in alive if p.role and ROLE_INFO[p.role].camp == "god"]
civs = [p for p in alive if p.role and ROLE_INFO[p.role].camp == "civilian"]
if not wolves:
self.winner = "good"
self.winner_text = "🎉 好人阵营胜利!所有狼人已被消灭。"
return True
rule = self.settings.get("win_rule", "bian")
if rule == "bian":
if not gods:
self.winner = "wolf"
self.winner_text = "🐺 狼人阵营胜利!(屠边:神职已全部出局)"
return True
if not civs:
self.winner = "wolf"
self.winner_text = "🐺 狼人阵营胜利!(屠边:村民已全部出局)"
return True
else:
if len(wolves) >= len(gods) + len(civs):
self.winner = "wolf"
self.winner_text = "🐺 狼人阵营胜利!(屠城:狼人数量不少于好人)"
return True
return False
async def _set_phase(self, phase: str, text: str) -> None:
self.phase = phase
self.phase_text = text
await self.room.broadcast_snapshot()
# ================================================================ 夜晚
async def _night(self) -> None:
self.last_votes = []
self.current_speaker = None
self.pending_night_deaths = []
self.night_death_causes = {}
await self._set_phase("night", f"第{self.day}夜 · 天黑请闭眼")
self._log_public(f"—— 第{self.day}夜 ——", "phase")
kill_target: int | None = None
saved = False
poison_target: int | None = None
# 守卫
guard = self._alive_role(Role.GUARD)
protect: int | None = None
if guard:
opts = [s for s in self.alive_seats() if s != self.guard_last]
await self._set_phase("night", f"第{self.day}夜 · 守卫请睁眼")
res = await self._wait("protect", [guard.seat], opts, self._timeout("night"),
"请选择今晚要守护的玩家(可空守,不能连守同一人)",
allow_skip=True)
protect = res.get(guard.seat)
if protect is not None:
self._log(f"你守护了 {protect} 号", seat=guard.seat, tag="private")
else:
self._log("你今晚选择空守", seat=guard.seat, tag="private")
# 狼人
wolves = [p for p in self.alive_players() if p.role == Role.WEREWOLF]
if wolves:
await self._set_phase("night", f"第{self.day}夜 · 狼人请睁眼")
wolf_seats = [w.seat for w in wolves]
victims = [s for s in self.alive_seats() if s not in wolf_seats]
kill_target = await self._wait_first_wins(
"kill", wolf_seats, victims, self._timeout("night"),
"狼队行动:选择今晚袭击的目标(队友先选的生效)")
if kill_target is not None:
self._log_for(set(wolf_seats), f"狼队决定袭击 {kill_target} 号", "private-wolf")
# 预言家
seer = self._alive_role(Role.SEER)
if seer:
results = self.seer_results.setdefault(seer.seat, [])
checked = {r["seat"] for r in results}
opts = [s for s in self.alive_seats() if s != seer.seat and s not in checked]
if opts:
await self._set_phase("night", f"第{self.day}夜 · 预言家请睁眼")
res = await self._wait("check", [seer.seat], opts, self._timeout("night"),
"请选择今晚要查验的玩家")
target = res.get(seer.seat)
if target is not None:
camp = "狼人" if self.player(target).role == Role.WEREWOLF else "好人"
results.append({"seat": target, "result": camp})
self._log(f"查验结果:{target} 号是「{camp}」", seat=seer.seat, tag="private")
# 女巫
witch = self._alive_role(Role.WITCH)
if witch:
pot = self.potions[witch.seat]
if pot["antidote"] or pot["poison"]:
await self._set_phase("night", f"第{self.day}夜 · 女巫请睁眼")
if pot["antidote"] and kill_target is not None:
can_act = kill_target != witch.seat or self.day == 1 # 首夜可自救
if can_act:
hint = f"今晚 {kill_target} 号被袭击了。" + ("(你可以在首夜自救)" if kill_target == witch.seat else "")
res = await self._wait("witch_save", [witch.seat], [kill_target],
self._timeout("night"), hint + " 是否使用解药?",
allow_skip=True)
if res.get(witch.seat) is not None:
pot["antidote"] = False
saved = True
self._log(f"你使用解药救下了 {kill_target} 号", seat=witch.seat, tag="private")
else:
self._log(f"你选择不救 {kill_target} 号", seat=witch.seat, tag="private")
else:
self._log("你今晚被袭击了(非首夜,无法自救)", seat=witch.seat, tag="private")
if pot["poison"] and not saved:
opts = [s for s in self.alive_seats() if s != witch.seat]
res = await self._wait("witch_poison", [witch.seat], opts,
self._timeout("night"), "是否使用毒药?选择要毒杀的玩家",
allow_skip=True)
poison_target = res.get(witch.seat)
if poison_target is not None:
pot["poison"] = False
self._log(f"你毒杀了 {poison_target} 号", seat=witch.seat, tag="private")
else:
self._log("你今晚没有用毒", seat=witch.seat, tag="private")
# 结算夜晚死亡
self.guard_last = protect
deaths: list[int] = []
if kill_target is not None:
if protect == kill_target and saved:
deaths.append(kill_target) # 同守同救
self.night_death_causes[kill_target] = "wolf"
elif protect == kill_target or saved:
pass
else:
deaths.append(kill_target)
self.night_death_causes[kill_target] = "wolf"
if poison_target is not None and poison_target not in deaths:
deaths.append(poison_target)
self.night_death_causes[poison_target] = "poison"
self.pending_night_deaths = deaths
async def _dawn(self) -> None:
await self._set_phase("dawn", f"第{self.day}天 · 天亮了")
self._log_public(f"—— 第{self.day}天 ——", "phase")
deaths = sorted(set(self.pending_night_deaths))
if not deaths:
self._log_public("昨夜是平安夜,无人出局。", "night")
else:
names = "、".join(f"{s}号" for s in deaths)
self._log_public(f"昨夜 {names} 出局。", "night")
for s in deaths:
p = self.player(s)
p.alive = False
cause = self.night_death_causes.get(s, "wolf")
self._log(f"你死了({'被毒杀' if cause == 'poison' else '被狼人袭击'})",
seat=s, tag="private")
await self.room.broadcast_snapshot()
if self.sheriff_seat is not None and self.sheriff_seat in deaths:
await self._badge_pass(self.sheriff_seat)
for s in deaths:
await self._maybe_hunter_shoot(s, poisoned=(self.night_death_causes.get(s) == "poison"))
async def _maybe_hunter_shoot(self, seat: int, *, poisoned: bool) -> None:
p = self.player(seat)
if p.role != Role.HUNTER:
return
if poisoned:
self._log("你被毒杀,无法开枪", seat=seat, tag="private")
return
opts = self.alive_seats()
if not opts:
return
await self._set_phase("dawn", f"{seat}号猎人 · 可发动技能")
self._log_public(f"{seat}号是猎人,可以开枪……", "system")
res = await self._wait("shoot", [seat], opts, self._timeout("vote"),
"你可以开枪带走一名玩家", allow_skip=True)
target = res.get(seat)
if target is None:
self._log_public(f"{seat}号猎人选择不开枪。")
return
victim = self.player(target)
victim.alive = False
self._log_public(f"🏹 {seat}号猎人开枪带走了 {target}号!", "night")
self._log("你死了(被猎人枪杀)", seat=target, tag="private")
await self.room.broadcast_snapshot()
if self.sheriff_seat == target:
await self._badge_pass(target)
async def _badge_pass(self, dead_sheriff: int) -> None:
self.sheriff_seat = None
opts = self.alive_seats()
if not opts:
return
await self._set_phase(self.phase, f"{dead_sheriff}号警长死亡 · 警徽流转")
self._log_public(f"{dead_sheriff}号警长的警徽等待移交……", "system")
res = await self._wait("badge", [dead_sheriff], opts, self._timeout("vote"),
"你可以将警徽移交给一名存活玩家,或撕毁警徽", allow_skip=True)
target = res.get(dead_sheriff)
if target is None:
self._log_public(f"{dead_sheriff}号撕毁了警徽,本局再无警长。")
else:
self.sheriff_seat = target
self._log_public(f"{dead_sheriff}号将警徽移交给了 {target}号。")
# ================================================================ 白天
async def _sheriff_election(self) -> None:
await self._set_phase("sheriff_elect", "警长竞选")
self._log_public("—— 警长竞选 ——", "phase")
alive = self.alive_seats()
res = await self._wait("elect", alive, [1], self._timeout("vote"),
"是否上警竞选警长?(警长投票计 1.5 票)", allow_skip=True)
self.candidates = [s for s in alive if res.get(s) == 1]
if not self.candidates:
self._log_public("无人上警,本局没有警长。")
self.candidates = []
await self.room.broadcast_snapshot()
return
if len(self.candidates) == 1:
self.sheriff_seat = self.candidates[0]
self._log_public(f"仅 {self.sheriff_seat}号 上警,自动当选警长。")
self._log("你当选了警长(投票计 1.5 票)", seat=self.sheriff_seat, tag="private")
self.candidates = []
await self.room.broadcast_snapshot()
return
self._log_public("上警玩家:" + "、".join(f"{s}号" for s in self.candidates))
for s in self.candidates:
if not self.player(s).alive:
continue
await self._speech_one(s, purpose="election")
voters = [s for s in self.alive_seats() if s not in self.candidates]
if not voters:
self.sheriff_seat = random.choice(self.candidates)
self._log_public(f"无人可投票,随机任命 {self.sheriff_seat}号 为警长。")
self._log("你当选了警长(投票计 1.5 票)", seat=self.sheriff_seat, tag="private")
self.candidates = []
await self.room.broadcast_snapshot()
return
await self._set_phase("sheriff_elect", "警长竞选 · 投票")
res = await self._wait("sheriff_vote", voters, list(self.candidates),
self._timeout("vote"), "请投票选出警长(不可弃票)")
self.last_votes = [{"voter": v, "target": t} for v, t in res.items() if t is not None]
counts: dict[int, float] = {}
for v, t in res.items():
if t is not None:
counts[t] = counts.get(t, 0) + 1.0
tally = ",".join(f"{s}号 {counts.get(s, 0):g} 票" for s in self.candidates)
top = max(counts.values()) if counts else 0
leaders = sorted([s for s, c in counts.items() if c == top])
if len(leaders) > 1:
self.sheriff_seat = random.choice(leaders)
self._log_public(f"警长竞选平票({tally}),随机由 {self.sheriff_seat}号 当选。")
else:
self.sheriff_seat = leaders[0]
self._log_public(f"{self.sheriff_seat}号当选警长!({tally})")
self._log("你当选了警长(投票计 1.5 票)", seat=self.sheriff_seat, tag="private")
self.candidates = []
await self.room.broadcast_snapshot()
async def _day_speeches(self) -> None:
await self._set_phase("speech", f"第{self.day}天 · 白天发言")
deaths = sorted(set(self.pending_night_deaths))
if deaths:
self._log_public("发言开始,从死者下家开始按座位顺序发言。", "phase")
start = deaths[0] % self.board.seat_count + 1
else:
base = self.sheriff_seat if self.sheriff_seat else 0
self._log_public("发言开始,从警长下家开始按座位顺序发言。", "phase")
start = base % self.board.seat_count + 1
alive = self.alive_seats()
order = [s for s in sorted(alive, key=lambda x: (x - start) % self.board.seat_count)]
for s in order:
if self.winner:
return
await self._speech_one(s, purpose="day")
async def _speech_one(self, seat: int, *, purpose: str) -> None:
p = self.player(seat)
if not p.alive:
return
p.speaking = True
self.current_speaker = seat
label = "竞选发言" if purpose == "election" else "发言"
await self._set_phase("speech", f"第{self.day}天 · {seat}号{label}中")
pend = Pending("speech", [seat], None, "轮到你发言了", input_text=True, purpose=purpose)
self._pending["speech"] = pend
loop = asyncio.get_running_loop()
fut = loop.create_future()
pend.futures[seat] = fut
pend.deadline = loop.time() + self._timeout("speech")
ai_task = None
if p.is_ai:
ai_task = asyncio.create_task(self._ai_speak(p, pend))
await self.room.broadcast_snapshot()
timeout = self._timeout("speech")
try:
await asyncio.wait_for(asyncio.shield(fut), timeout)
except asyncio.TimeoutError:
pass
finally:
self._pending.pop("speech", None)
if ai_task:
ai_task.cancel()
p.speaking = False
self.current_speaker = None
text = fut.result().strip() if fut.done() and not fut.cancelled() else ""
if not text:
text = "(过)"
self._log_public(f"{seat}号 {p.name}:{text}", "speech")
self._scan_claims(seat, text)
await self.room.broadcast_snapshot()
def _scan_claims(self, seat: int, text: str) -> None:
if ("预言家" in text) and ("我" in text or "本人" in text):
self.claimed_seers.add(seat)
async def _day_vote(self) -> None:
await self._set_phase("vote", f"第{self.day}天 · 投票放逐")
self._log_public("—— 投票 ——", "phase")
alive = self.alive_seats()
if len(alive) < 2:
return
res = await self._wait("vote", alive, alive, self._timeout("vote"),
"请投票放逐一名玩家(可弃票)", allow_skip=True)
self.last_votes = [{"voter": v, "target": t} for v, t in res.items()]
counts: dict[int, float] = {}
for v, t in res.items():
if t is None:
continue
weight = 1.5 if v == self.sheriff_seat else 1.0
counts[t] = counts.get(t, 0) + weight
detail = ",".join(f"{s}号 {counts.get(s, 0):g} 票" for s in alive if counts.get(s))
abstain = [v for v, t in res.items() if t is None]
if abstain:
detail += (";" if detail else "") + f"弃票:{'、'.join(str(v) + '号' for v in abstain)}"
self._log_public(f"投票结果:{detail or '无人投票'}", "vote")
await self.room.broadcast_snapshot()
if not counts:
self._log_public("无人得票,无人出局。")
return
top = max(counts.values())
leaders = sorted(s for s, c in counts.items() if c == top)
if len(leaders) > 1:
self._log_public(f"{'、'.join(str(s) + '号' for s in leaders)} 平票,本日无人出局。")
return
out = leaders[0]
victim = self.player(out)
victim.alive = False
self._log_public(f"⚖️ {out}号被投票放逐。", "vote")
self._log("你被投票放逐了", seat=out, tag="private")
await self.room.broadcast_snapshot()
if victim.role == Role.HUNTER:
await self._maybe_hunter_shoot(out, poisoned=False)
if self.sheriff_seat == out:
await self._badge_pass(out)
# ================================================================ 行动登记
def handle_action(self, seat: int, key: str | None, target) -> tuple[bool, str]:
if key is None:
return False, "缺少操作类型"
pend = self._pending.get(key)
if pend is None:
return False, "当前没有等待你的操作"
if pend.resolved:
return False, "该决定已完成(队友先提交了)"
if seat not in pend.seats:
return False, "现在不是你的行动阶段"
fut = pend.futures.get(seat)
if fut is not None and fut.done():
return False, "你已经提交过了"
if key == "vote" and target == seat:
return False, "不能投自己"
if target is None:
if not pend.allow_skip:
return False, "必须选择一个目标"
elif pend.options is not None and target not in pend.options:
return False, "无效的目标"
self._resolve(pend, seat, target)
return True, ""
def handle_speech(self, seat: int, text: str) -> tuple[bool, str]:
pend = self._pending.get("speech")
if pend is None:
return False, "当前没有轮到你发言"
if seat not in pend.seats:
return False, "当前没有轮到你发言"
fut = pend.futures.get(seat)
if fut is None or fut.done():
return False, "发言已提交"
text = (text or "").strip()[:300]
fut.set_result(text)
return True, ""
def _resolve(self, pend: Pending, seat: int, target) -> None:
if pend.resolved:
return
if pend.first_wins:
if pend.shared is not None and not pend.shared.done():
pend.shared.set_result(target)
pend.resolved = True
return
fut = pend.futures.get(seat)
if fut is not None and not fut.done():
fut.set_result(target)
async def _wait(self, key: str, seats: list[int], options: list[int] | None,
timeout: int, text: str, *, allow_skip: bool = False,
purpose: str = "") -> dict[int, int | None]:
pend = Pending(key, seats, options, text, allow_skip=allow_skip, purpose=purpose)
self._pending[key] = pend
loop = asyncio.get_running_loop()
pend.deadline = loop.time() + timeout
ai_tasks: list[asyncio.Task] = []
for s in seats:
p = self.player(s)
fut = loop.create_future()
pend.futures[s] = fut
if p.is_ai:
ai_tasks.append(asyncio.create_task(self._ai_act(p, pend)))
await self.room.broadcast_snapshot()
try:
await asyncio.wait_for(asyncio.gather(*pend.futures.values()), timeout=timeout)
except asyncio.TimeoutError:
pass
finally:
for t in ai_tasks:
t.cancel()
self._pending.pop(key, None)
results: dict[int, int | None] = {}
for s, fut in pend.futures.items():
try:
results[s] = fut.result() if fut.done() else None
except Exception:
results[s] = None
await self.room.broadcast_snapshot()
return results
async def _wait_first_wins(self, key: str, seats: list[int], options: list[int],
timeout: int, text: str) -> int | None:
pend = Pending(key, seats, options, text, allow_skip=True, first_wins=True)
self._pending[key] = pend
loop = asyncio.get_running_loop()
pend.deadline = loop.time() + timeout
ai_seats = [s for s in seats if self.player(s).is_ai]
ai_task = None
if ai_seats:
leader = random.choice(ai_seats) # 由一名 AI 狼主导提议,避免重复调用
ai_task = asyncio.create_task(self._ai_act(self.player(leader), pend))
await self.room.broadcast_snapshot()
try:
await asyncio.wait_for(asyncio.shield(pend.shared), timeout=timeout)
except asyncio.TimeoutError:
pass
finally:
if ai_task:
ai_task.cancel()
pend.resolved = True
self._pending.pop(key, None)
result = None
if pend.shared is not None and pend.shared.done() and not pend.shared.cancelled():
try:
result = pend.shared.result()
except Exception:
result = None
await self.room.broadcast_snapshot()
return result
# ================================================================ AI
def _ai_context(self, player: Player, pend: Pending | None = None, purpose: str = "") -> dict:
priv: list[str] = []
role = player.role
info = ROLE_INFO[role] if role else None
if role == Role.WEREWOLF:
mates = sorted(p.seat for p in self.room.players.values()
if p.role == Role.WEREWOLF and p.seat != player.seat)
priv.append("狼队友:" + ("、".join(f"{s}号" for s in mates) if mates else "无"))
if role == Role.SEER:
rs = self.seer_results.get(player.seat, [])
priv.append("查验记录:" + (";".join(f"{r['seat']}号是{r['result']}" for r in rs) if rs else "暂无"))
if role == Role.WITCH:
pot = self.potions[player.seat]
priv.append(f"解药{'还在' if pot['antidote'] else '已用'},毒药{'还在' if pot['poison'] else '已用'}")
if role == Role.GUARD:
priv.append(f"昨晚守护了{self.guard_last}号" if self.guard_last else "昨晚空守")
if self.sheriff_seat is not None:
priv.append(f"当前警长是{self.sheriff_seat}号(投票计1.5票)")
alive = self.alive_players()
log_lines = []
for e in self.log[-80:]:
if e["visible"] is None:
log_lines.append(e["text"])
log_text = "\n".join(log_lines)
if len(log_text) > 6000:
log_text = log_text[-6000:]
return {
"seat": player.seat,
"name": player.name,
"persona": player.persona,
"role": role.value if role else "",
"role_name": info.name if info else "",
"role_desc": info.description if info else "",
"private": priv,
"day": self.day,
"phase_text": self.phase_text,
"alive": [{"seat": p.seat, "name": p.name} for p in alive],
"options": pend.options if pend else [],
"key": pend.key if pend else "",
"question": pend.text if pend else "",
"purpose": purpose,
"claimed_seers": sorted(self.claimed_seers),
"sheriff_seat": self.sheriff_seat,
"log_text": log_text,
}
async def _ai_act(self, player: Player, pend: Pending) -> None:
try:
agent = self._agent(player)
ctx = self._ai_context(player, pend)
if pend.resolved:
return
target = await agent.decide(pend.key, ctx)
if target is not None and pend.options is not None and target not in pend.options:
target = None if pend.allow_skip else random.choice(pend.options)
self._resolve(pend, player.seat, target)
except asyncio.CancelledError:
raise
except Exception:
if not pend.resolved and pend.allow_skip:
self._resolve(pend, player.seat, None)
async def _ai_speak(self, player: Player, pend: Pending) -> None:
try:
agent = self._agent(player)
ctx = self._ai_context(player, pend, purpose=pend.purpose)
text = await agent.speak(ctx)
fut = pend.futures.get(player.seat)
if fut is not None and not fut.done():
fut.set_result((text or "").strip()[:300])
except asyncio.CancelledError:
raise
except Exception:
pass
# ================================================================ 快照
def snapshot_for(self, seat: int | None) -> dict:
players_game = {}
# 由 Room.snapshot_for 组装玩家视图,这里只补充对局相关字段
pend_view = None
if seat is not None:
for pend in self._pending.values():
if pend.resolved or seat not in pend.seats:
continue
fut = pend.futures.get(seat)
if fut is not None and fut.done():
continue
if pend.input_text:
pend_view = {
"key": pend.key, "options": [], "text": pend.text,
"allow_skip": False, "input_text": True,
"purpose": pend.purpose, "seconds_left": pend.seconds_left(),
}
else:
pend_view = {
"key": pend.key,
"options": pend.options or [],
"text": pend.text,
"allow_skip": pend.allow_skip,
"input_text": False,
"purpose": "",
"seconds_left": pend.seconds_left(),
}
break
me = None
if seat is not None:
p = self.room.players.get(seat)
if p is not None and p.role is not None:
me = {"seat": seat}
if p.role == Role.WEREWOLF:
me["teammates"] = sorted(q.seat for q in self.room.players.values()
if q.role == Role.WEREWOLF and q.seat != seat)
if p.role == Role.SEER:
me["seer_results"] = self.seer_results.get(seat, [])
if p.role == Role.WITCH:
me["potions"] = self.potions.get(seat)
log_view = []
for e in self.log:
if e["visible"] is None or (seat is not None and seat in e["visible"]):
log_view.append({"text": e["text"], "kind": "private" if e["visible"] else "public",
"tag": e["tag"]})
return {
"me_extras": me,
"log": log_view,
"pending": pend_view,
"sheriff_seat": self.sheriff_seat,
"candidates": list(self.candidates),
"current_speaker": self.current_speaker,
"last_votes": self.last_votes,
"winner": self.winner,
"winner_text": self.winner_text,
}
+38
View File
@@ -0,0 +1,38 @@
"""玩家实体:人类(持 WebSocket 连接)或 AI。"""
from __future__ import annotations
import secrets
from fastapi import WebSocket
from .roles import Role
class Player:
def __init__(self, seat: int, name: str, is_ai: bool = False) -> None:
self.seat = seat
self.name = name
self.is_ai = is_ai
self.token = secrets.token_hex(8)
self.role: Role | None = None
self.alive = True
self.ws: WebSocket | None = None # AI 恒为 None
self.speaking = False # 当前正在发言(仅用于 UI 高亮)
self.persona: str = "" # AI 性格(LLM 提示词用)
# ------------------------------------------------------------------
@property
def connected(self) -> bool:
return self.is_ai or self.ws is not None
def bind_ws(self, ws: WebSocket) -> None:
self.ws = ws
def unbind_ws(self, ws: WebSocket) -> None:
if self.ws is ws:
self.ws = None
def reset_for_new_game(self, role: Role) -> None:
self.role = role
self.alive = True
self.speaking = False
+63
View File
@@ -0,0 +1,63 @@
"""角色定义。"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Role(str, Enum):
WEREWOLF = "werewolf"
VILLAGER = "villager"
SEER = "seer"
WITCH = "witch"
HUNTER = "hunter"
GUARD = "guard"
@dataclass(frozen=True)
class RoleInfo:
role: Role
name: str # 中文名
camp: str # "wolf" | "god" | "civilian"
emoji: str
description: str
ROLE_INFO: dict[Role, RoleInfo] = {
Role.WEREWOLF: RoleInfo(
Role.WEREWOLF, "狼人", "wolf", "🐺",
"每晚与同伴共同袭击一名玩家,白天隐藏身份,误导好人投票。",
),
Role.VILLAGER: RoleInfo(
Role.VILLAGER, "村民", "civilian", "👨‍🌾",
"没有主动技能,白天通过发言与投票找出狼人。",
),
Role.SEER: RoleInfo(
Role.SEER, "预言家", "god", "🔮",
"每晚查验一名玩家的阵营(好人 / 狼人),带领好人寻找狼人。",
),
Role.WITCH: RoleInfo(
Role.WITCH, "女巫", "god", "🧪",
"拥有一瓶解药和一瓶毒药。解药可救活当晚被袭击者,毒药可毒杀一名玩家,每夜只能用一瓶。首夜可自救。",
),
Role.HUNTER: RoleInfo(
Role.HUNTER, "猎人", "god", "🏹",
"被狼人袭击或被投票放逐时,可开枪带走一名玩家(被毒杀时无法开枪)。",
),
Role.GUARD: RoleInfo(
Role.GUARD, "守卫", "god", "🛡️",
"每晚守护一名玩家免受狼人袭击,不能连续两晚守护同一人。同守同救会死亡。",
),
}
WOLF_TEAM = {Role.WEREWOLF}
GOD_TEAM = {Role.SEER, Role.WITCH, Role.HUNTER, Role.GUARD}
CIVILIAN_TEAM = {Role.VILLAGER}
def role_camp(role: Role) -> str:
return ROLE_INFO[role].camp
def is_wolf(role: Role) -> bool:
return role == Role.WEREWOLF
+418
View File
@@ -0,0 +1,418 @@
"""房间与连接管理:座位分配、广播/私发快照、房主控制、断线重连。"""
from __future__ import annotations
import asyncio
import json
import random
import secrets
import string
import time
from fastapi import WebSocket
from .board import BOARDS, Board, get_board
from .engine import Engine
from .player import Player
from .roles import ROLE_INFO
AI_NAMES = [
"李白", "杜甫", "白居易", "苏轼", "李清照", "辛弃疾", "王维", "杜牧",
"陶渊明", "屈原", "韩愈", "欧阳修", "王安石", "陆游", "孟浩然", "刘禹锡",
"曹操", "诸葛亮", "赵云", "司马懿",
]
AI_PERSONAS = [
"直率暴躁,容易上头,发言简短有力",
"谨慎理性,逻辑流,喜欢分析时间线和票型",
"幽默话痨,喜欢开玩笑,但关键时刻很准",
"老练沉稳,话不多但每句都有分量",
"激进冲锋,敢说敢投,喜欢带节奏",
"温柔细腻,善于观察别人的表情和语气",
"老好人形象,总是和稀泥,偶尔语出惊人",
"策略家,喜欢钓鱼和反向操作",
]
class Spectator:
"""房间满员或对局中进入的观战者。"""
def __init__(self, ws: WebSocket, name: str) -> None:
self.seat = None
self.name = name
self.ws = ws
self.token = secrets.token_hex(8)
self.is_ai = False
self.note = ""
def _gen_code() -> str:
return "".join(random.choices("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", k=4))
class RoomManager:
ROOM_TTL_SECONDS = 600 # 房间内没有真人连接超过该时长后回收
def __init__(self) -> None:
self.rooms: dict[str, Room] = {}
self._lock = asyncio.Lock()
self._sweeper: asyncio.Task | None = None
def get(self, code: str) -> Room | None:
return self.rooms.get(code.upper())
def create(self, board_key: str = "classic12") -> str:
for _ in range(100):
code = _gen_code()
if code not in self.rooms:
room = Room(code, board_key)
self.rooms[code] = room
self._ensure_sweeper()
return code
raise RuntimeError("无法生成房间号")
def remove(self, code: str) -> None:
self.rooms.pop(code, None)
def _ensure_sweeper(self) -> None:
if self._sweeper is None or self._sweeper.done():
self._sweeper = asyncio.create_task(self._sweep_loop())
async def _sweep_loop(self) -> None:
while True:
await asyncio.sleep(30)
try:
for code, room in list(self.rooms.items()):
if room.human_connected():
room.empty_since = None
continue
if room.empty_since is None:
room.empty_since = time.time()
elif time.time() - room.empty_since > self.ROOM_TTL_SECONDS:
self.rooms.pop(code, None)
except Exception:
pass
MANAGER = RoomManager()
class Room:
def __init__(self, code: str, board_key: str) -> None:
self.code = code
self.lock = asyncio.Lock() # 保护成员增删
self.send_lock = asyncio.Lock() # 串行化所有 WS 发送
self.players: dict[int, Player] = {}
self.spectators: list[Spectator] = []
self.host_seat: int | None = None
self.settings: dict = {
"board": board_key if board_key in BOARDS else "classic12",
"win_rule": "bian", # bian=屠边 cheng=屠城
"night_seconds": 45,
"speech_seconds": 50,
"vote_seconds": 30,
}
self.engine: Engine | None = None
self.engine_task: asyncio.Task | None = None
self.empty_since: float | None = None # 无真人连接的起始时间(供回收器使用)
self._ai_name_seq = 0
# ------------------------------------------------------------------ 基础
@property
def board(self) -> Board:
return get_board(self.settings["board"])
@property
def seat_count(self) -> int:
return self.board.seat_count
def free_seats(self) -> list[int]:
return [s for s in range(1, self.seat_count + 1) if s not in self.players]
def game_running(self) -> bool:
return self.engine is not None and self.engine.running
def next_ai_name(self) -> tuple[str, str]:
name = AI_NAMES[self._ai_name_seq % len(AI_NAMES)]
persona = AI_PERSONAS[self._ai_name_seq % len(AI_PERSONAS)]
self._ai_name_seq += 1
return f"AI·{name}", persona
def settings_public(self) -> dict:
b = self.board
return {
"board": b.key,
"board_name": b.name,
"win_rule": self.settings["win_rule"],
"night_seconds": self.settings["night_seconds"],
"speech_seconds": self.settings["speech_seconds"],
"vote_seconds": self.settings["vote_seconds"],
}
# ------------------------------------------------------------------ 加入 / 离开
async def attach(self, ws: WebSocket, name: str, token: str | None):
"""返回 (成员, 错误信息)。成员是 Player 或 Spectator。"""
async with self.lock:
if token:
for p in self.players.values():
if not p.is_ai and p.token == token:
p.bind_ws(ws)
return p, ""
for s in list(self.spectators):
if s.token == token:
s.ws = ws
s.name = name
return s, ""
if self.game_running() or not self.free_seats():
spec = Spectator(ws, name)
spec.note = "对局进行中,你已进入观战" if self.game_running() else "房间座位已满,你已进入观战"
self.spectators.append(spec)
return spec, ""
seat = self.free_seats()[0]
player = Player(seat, name)
player.bind_ws(ws)
self.players[seat] = player
if self.host_seat is None or self.host_seat not in self.players:
self.host_seat = seat
return player, ""
async def detach(self, member, ws: WebSocket) -> None:
async with self.lock:
if isinstance(member, Spectator):
if member.ws is ws:
self.spectators.remove(member)
else:
member.unbind_ws(ws)
if not self.game_running():
self.players.pop(member.seat, None)
if self.host_seat == member.seat:
humans = [p for p in self.players.values()
if not p.is_ai and p.connected]
self.host_seat = humans[0].seat if humans else None
if self.human_connected():
await self.broadcast_snapshot()
def human_connected(self) -> bool:
return (any(not p.is_ai and p.connected for p in self.players.values())
or bool(self.spectators))
# ------------------------------------------------------------------ 消息处理
async def handle_message(self, member, data: dict) -> None:
mtype = data.get("type")
if mtype == "control":
if member.seat is None or member.seat != self.host_seat:
await self.send_error(member, "只有房主可以执行该操作")
return
await self.handle_control(data.get("op"), data)
elif mtype == "action":
if member.seat is None or self.engine is None:
return
ok, err = self.engine.handle_action(member.seat, data.get("action"), data.get("target"))
if not ok:
await self.send_error(member, err)
else:
await self.broadcast_snapshot()
elif mtype == "speech":
if member.seat is None or self.engine is None:
return
ok, err = self.engine.handle_speech(member.seat, str(data.get("text", "")))
if not ok:
await self.send_error(member, err)
else:
await self.broadcast_snapshot()
else:
await self.send_error(member, f"未知消息类型: {mtype}")
async def handle_control(self, op: str | None, data: dict) -> None:
err = ""
if op == "add_ai":
if self.game_running():
err = "对局中无法添加 AI"
else:
free = self.free_seats()
if not free:
err = "没有空位了"
else:
name, persona = self.next_ai_name()
ai = Player(free[0], name, is_ai=True)
ai.persona = persona
self.players[free[0]] = ai
elif op == "fill_ai":
if self.game_running():
err = "对局中无法添加 AI"
else:
for seat in self.free_seats():
name, persona = self.next_ai_name()
ai = Player(seat, name, is_ai=True)
ai.persona = persona
self.players[seat] = ai
elif op == "remove":
if self.game_running():
err = "对局中无法移除玩家"
else:
seat = data.get("seat")
p = self.players.get(seat)
if p is None or seat == self.host_seat:
err = "无法移除该玩家"
else:
del self.players[seat]
elif op == "update_settings":
s = data.get("settings") or {}
if self.game_running():
err = "对局中无法修改设置"
else:
if s.get("board") in BOARDS:
# 换板子会清空与板子人数冲突的玩家
self.settings["board"] = s["board"]
for seat in list(self.players):
if seat > self.seat_count:
del self.players[seat]
self.host_seat = next(
(p.seat for p in self.players.values() if not p.is_ai), None)
if s.get("win_rule") in ("bian", "cheng"):
self.settings["win_rule"] = s["win_rule"]
for k in ("night_seconds", "speech_seconds", "vote_seconds"):
if k in s:
try:
self.settings[k] = max(10, min(180, int(s[k])))
except (TypeError, ValueError):
pass
elif op == "start":
err = await self.start_game()
elif op == "reset":
if self.engine is None or self.engine.running:
err = "对局尚未结束"
else:
for p in self.players.values():
p.role = None
p.alive = True
p.speaking = False
self.engine = None
self.engine_task = None
# 房主已离线时把房主移交给第一个在线真人
host = self.players.get(self.host_seat) if self.host_seat else None
if host is None or host.is_ai or not host.connected:
humans = [p for p in self.players.values() if not p.is_ai and p.connected]
self.host_seat = humans[0].seat if humans else None
else:
err = f"未知操作: {op}"
if err:
host = self.players.get(self.host_seat)
if host:
await self.send_error(host, err)
await self.broadcast_snapshot()
async def start_game(self) -> str:
if self.game_running():
return "对局已经开始了"
if len(self.players) < 4:
return "至少需要 4 名玩家"
if self.free_seats():
return "还有空位,请先补满玩家(可一键补 AI)"
board = self.board
self._ai_name_seq = 0
self.engine = Engine(self, board, dict(self.settings))
self.engine_task = asyncio.create_task(self.engine.run())
return ""
# ------------------------------------------------------------------ 发送
async def _send_json(self, ws: WebSocket, payload: dict) -> bool:
try:
async with self.send_lock:
await ws.send_text(json.dumps(payload, ensure_ascii=False))
return True
except Exception:
return False
async def send_error(self, member, message: str) -> None:
if member.ws is not None:
await self._send_json(member.ws, {"type": "error", "message": message})
async def send_snapshot_to(self, member) -> None:
if member.ws is not None:
await self._send_json(member.ws, {
"type": "snapshot",
"data": self.snapshot_for(member),
})
async def broadcast_snapshot(self) -> None:
members = [p for p in self.players.values() if not p.is_ai and p.ws is not None]
members += list(self.spectators)
for m in members:
await self.send_snapshot_to(m)
# ------------------------------------------------------------------ 快照
def snapshot_for(self, member) -> dict:
seat = member.seat if isinstance(member, Player) else None
engine = self.engine
reveal = bool(engine and engine.reveal)
players_view = []
for s in range(1, self.seat_count + 1):
p = self.players.get(s)
if p is None:
players_view.append({
"seat": s, "name": None, "empty": True, "alive": False,
"is_ai": False, "connected": False, "speaking": False,
"sheriff": False, "candidate": False,
"role_key": None, "role_name": None, "is_me": False,
})
continue
role_key = role_name = None
if p.role is not None:
if reveal:
role_key, role_name = p.role.value, ROLE_INFO[p.role].name
elif engine is not None and seat is not None:
rk, rn = engine.visible_role_for(seat, p)
role_key, role_name = rk, rn
players_view.append({
"seat": s, "name": p.name, "empty": False, "alive": p.alive,
"is_ai": p.is_ai, "connected": p.connected, "speaking": p.speaking,
"sheriff": engine.sheriff_seat == s if engine else False,
"candidate": engine.is_candidate(s) if engine else False,
"role_key": role_key, "role_name": role_name, "is_me": s == seat,
})
me = None
if seat is not None:
p = self.players[seat]
me = {
"seat": seat, "name": p.name, "alive": p.alive,
"is_host": self.host_seat == seat, "is_ai": p.is_ai,
}
if p.role is not None:
info = ROLE_INFO[p.role]
me.update({
"role_key": p.role.value, "role_name": info.name,
"role_camp": info.camp, "role_emoji": info.emoji,
"role_desc": info.description,
})
data = {
"type": "snapshot",
"room_code": self.code,
"phase": engine.phase if engine else "lobby",
"day": engine.day if engine else 0,
"phase_text": engine.phase_text if engine else "等待开局",
"seat_count": self.seat_count,
"host_seat": self.host_seat,
"spectator": seat is None,
"spectator_note": getattr(member, "note", "") if seat is None else "",
"settings": self.settings_public(),
"players": players_view,
"me": me,
"token": member.token,
"log": [],
"pending": None,
"sheriff_seat": None,
"candidates": [],
"current_speaker": None,
"last_votes": [],
"winner": None,
"winner_text": None,
}
if engine is not None:
data.update(engine.snapshot_for(seat))
extras = data.pop("me_extras", None)
if extras and data["me"] is not None:
data["me"].update(extras)
return data