419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""房间与连接管理:座位分配、广播/私发快照、房主控制、断线重连。"""
|
|
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
|