Initial commit: 网页联机狼人杀(FastAPI+WebSocket 后端 / React+TS+Sass 前端)
This commit is contained in:
commit
7f5ea6bee8
55 files changed
+6999
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user