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,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