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

+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)