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.
+67
View File
@@ -0,0 +1,67 @@
"""配置加载:backend/config.json(可从 config.example.json 复制修改)。
未配置 api_key 时,AI 玩家自动回退为内置启发式 AI,游戏依然完整可玩。
支持环境变量覆盖:WEREWOLF_LLM_BASE_URL / WEREWOLF_LLM_API_KEY / WEREWOLF_LLM_MODEL。
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = BACKEND_DIR / "config.json"
EXAMPLE_PATH = BACKEND_DIR / "config.example.json"
@dataclass
class LLMConfig:
base_url: str = ""
api_key: str = ""
model: str = ""
temperature: float = 0.8
@property
def configured(self) -> bool:
return bool(self.base_url and self.api_key and self.model)
@dataclass
class ServerConfig:
host: str = "0.0.0.0"
port: int = 8000
@dataclass
class AppConfig:
llm: LLMConfig = field(default_factory=LLMConfig)
server: ServerConfig = field(default_factory=ServerConfig)
def load_config() -> AppConfig:
cfg = AppConfig()
source = CONFIG_PATH
if not source.exists() and EXAMPLE_PATH.exists():
source = EXAMPLE_PATH
if source.exists():
try:
raw = json.loads(source.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
raw = {}
llm = raw.get("llm", {})
cfg.llm.base_url = llm.get("base_url", cfg.llm.base_url)
cfg.llm.api_key = llm.get("api_key", cfg.llm.api_key)
cfg.llm.model = llm.get("model", cfg.llm.model)
cfg.llm.temperature = float(llm.get("temperature", cfg.llm.temperature))
srv = raw.get("server", {})
cfg.server.host = srv.get("host", cfg.server.host)
cfg.server.port = int(srv.get("port", cfg.server.port))
cfg.llm.base_url = os.environ.get("WEREWOLF_LLM_BASE_URL", cfg.llm.base_url).strip()
cfg.llm.api_key = os.environ.get("WEREWOLF_LLM_API_KEY", cfg.llm.api_key).strip()
cfg.llm.model = os.environ.get("WEREWOLF_LLM_MODEL", cfg.llm.model).strip()
return cfg
CONFIG = load_config()
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
+101
View File
@@ -0,0 +1,101 @@
"""FastAPI 入口:REST 接口 + WebSocket 端点 + 托管前端构建产物。"""
from __future__ import annotations
from pathlib import Path
import uvicorn
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
from .config import CONFIG
from .game.room import MANAGER
app = FastAPI(title="狼人杀", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
ROOT = Path(__file__).resolve().parents[2]
DIST = ROOT / "frontend" / "dist"
# ---------------------------------------------------------------- REST
class CreateRoomReq(BaseModel):
board: str = "classic12"
host_name: str = "房主"
@app.get("/api/health")
async def health():
return {
"status": "ok",
"llm_configured": CONFIG.llm.configured,
"model": CONFIG.llm.model if CONFIG.llm.configured else None,
}
@app.get("/api/boards")
async def boards():
from .game.board import BOARDS
return [{"key": b.key, "name": b.name, "seat_count": b.seat_count}
for b in BOARDS.values()]
@app.post("/api/rooms")
async def create_room(req: CreateRoomReq):
code = MANAGER.create(req.board)
return {"code": code}
# ---------------------------------------------------------------- WebSocket
@app.websocket("/ws/{code}")
async def websocket_endpoint(ws: WebSocket, code: str):
await ws.accept()
room = MANAGER.get(code)
if room is None:
await ws.send_json({"type": "error", "message": "房间不存在或已解散"})
await ws.close(code=4404)
return
name = (ws.query_params.get("name") or "玩家").strip()[:12] or "玩家"
token = ws.query_params.get("token") or None
member, err = await room.attach(ws, name, token)
await room.send_snapshot_to(member)
if err:
await room.send_error(member, err)
try:
while True:
try:
data = await ws.receive_json()
except ValueError:
continue
await room.handle_message(member, data)
except WebSocketDisconnect:
await room.detach(member, ws)
except Exception:
await room.detach(member, ws)
# ---------------------------------------------------------------- 静态前端
@app.get("/{full_path:path}", include_in_schema=False)
async def spa(full_path: str):
if full_path.startswith("api/") or full_path == "api":
raise HTTPException(status_code=404)
if full_path and (DIST / full_path).is_file():
return FileResponse(DIST / full_path)
index = DIST / "index.html"
if index.is_file():
return FileResponse(index)
raise HTTPException(status_code=404, detail="前端未构建:请先在 frontend 目录执行 pnpm build")
def main() -> None:
uvicorn.run(app, host=CONFIG.server.host, port=CONFIG.server.port)
if __name__ == "__main__":
main()