Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)
This commit is contained in:
commit
7f5ea6bee8
55 files changed
+6999
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
||||
from .agent import LLMAgent, make_agent
|
||||
from .fallback import HeuristicAgent
|
||||
|
||||
__all__ = ["LLMAgent", "HeuristicAgent", "make_agent"]
|
||||
@@ -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)
|
||||
@@ -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}号的发言前后有点矛盾,今天我可能跟票他。"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user