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

+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