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