package com.webgame.webgamebackend.service; import com.alibaba.fastjson2.JSON; import com.webgame.webgamebackend.common.dto.game.*; import com.webgame.webgamebackend.common.dto.game.RoomDetailResponse.RoomPlayerDetail; import com.webgame.webgamebackend.common.event.RoomEventBus; import com.webgame.webgamebackend.common.event.SseEventPublisher; import com.webgame.webgamebackend.common.event.SseEventType; import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.ErrorCode; import com.webgame.webgamebackend.entities.*; import com.webgame.webgamebackend.repository.*; import com.webgame.webgamebackend.service.engine.*; import com.webgame.webgamebackend.ws.RoomSessionManager; import com.webgame.webgamebackend.ws.dto.WsMessage; import com.webgame.webgamebackend.ws.dto.WsOutboundMessage; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import java.io.IOException; import java.time.LocalDateTime; import java.util.*; import java.util.concurrent.*; import java.util.stream.Collectors; /** * 房间管理器 — 统一管理所有房间生命周期 * *

职责

* * *

房间数据持久化到 MySQL,运行时状态(游戏引擎引用、断线定时器) * 缓存在内存中。

*/ @Slf4j @Component public class RoomManager { private final GameConfigRepository gameConfigRepository; private final GameRoomRepository gameRoomRepository; private final RoomPlayerRepository roomPlayerRepository; private final UserRepository userRepository; private final GameRecordRepository gameRecordRepository; private final GameMoveRepository gameMoveRepository; private final RoomSessionManager sessionManager; private final GameEngineRegistry engineRegistry; private final SseEventPublisher sseEventPublisher; private final RoomEventBus eventBus; // ==================== 内存状态 ==================== /** 断线重连超时时间(秒) */ private static final long DISCONNECT_TIMEOUT_SECONDS = 60; /** 断线重连定时器线程池 */ private final ScheduledExecutorService reconnectScheduler = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "room-reconnect")); /** 房间运行时状态缓存: roomId → RoomRuntime */ private final ConcurrentHashMap roomRuntimes = new ConcurrentHashMap<>(); /** * 房间运行时状态(不可持久化的部分) */ private static class RoomRuntime { /** 活跃的游戏引擎(PLAYING 状态下非 null) */ volatile GameEngine gameEngine; /** 断线倒计时任务: userId → Future */ final ConcurrentHashMap> disconnectTimers = new ConcurrentHashMap<>(); RoomRuntime() {} RoomRuntime(GameEngine gameEngine) { this.gameEngine = gameEngine; } } public RoomManager(GameConfigRepository gameConfigRepository, GameRoomRepository gameRoomRepository, RoomPlayerRepository roomPlayerRepository, UserRepository userRepository, GameRecordRepository gameRecordRepository, GameMoveRepository gameMoveRepository, RoomSessionManager sessionManager, GameEngineRegistry engineRegistry, SseEventPublisher sseEventPublisher, RoomEventBus eventBus) { this.gameConfigRepository = gameConfigRepository; this.gameRoomRepository = gameRoomRepository; this.roomPlayerRepository = roomPlayerRepository; this.userRepository = userRepository; this.gameRecordRepository = gameRecordRepository; this.gameMoveRepository = gameMoveRepository; this.sessionManager = sessionManager; this.engineRegistry = engineRegistry; this.sseEventPublisher = sseEventPublisher; this.eventBus = eventBus; } // ==================== 房间 CRUD ==================== /** * 创建房间 * * @param request 创建房间请求 * @param creatorId 房主 accountId * @return 创建结果 */ public CreateRoomResponse createRoom(CreateRoomRequest request, String creatorId) { // 校验游戏存在且启用 GameConfigEntity gameConfig = gameConfigRepository.findByGameId(request.gameId()) .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); if (!gameConfig.getEnabled()) { throw new BusinessException(ErrorCode.GAME_NOT_FOUND); } // 校验 mode List modes = JSON.parseArray(gameConfig.getModes(), String.class); if (!modes.contains(request.mode())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode()); } // 校验 stakes List stakesOptions = JSON.parseArray(gameConfig.getStakesOptions(), Integer.class); if (!stakesOptions.contains(request.stakes())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的底注: " + request.stakes()); } // 检查用户是否已在其他房间 if (roomPlayerRepository.existsByUserId(creatorId)) { throw new BusinessException(ErrorCode.ALREADY_IN_ROOM); } // 生成桌号 Integer maxRoomNumber = gameRoomRepository.findMaxRoomNumberByGameId(request.gameId()); int nextRoomNumber = (maxRoomNumber == null) ? 1 : maxRoomNumber + 1; // 生成桌名 String roomName = (request.name() != null && !request.name().isBlank()) ? request.name() : "桌 " + String.format("%02d", nextRoomNumber); // 创建房间实体 GameRoomEntity room = new GameRoomEntity() .setGameId(request.gameId()) .setRoomNumber(nextRoomNumber) .setName(roomName) .setMaxPlayers(request.maxPlayers()) .setMode(request.mode()) .setStakes(request.stakes()) .setStatus("waiting") .setCreatorId(creatorId); // 密码明文存储 if (request.password() != null && !request.password().isBlank()) { room.setPassword(request.password()); } gameRoomRepository.save(room); // 房主自动加入(座位 1,已准备) RoomPlayerEntity creatorPlayer = new RoomPlayerEntity() .setRoomId(room.getId()) .setUserId(creatorId) .setSeatNumber(1) .setReadyStatus(true) .setJoinTime(LocalDateTime.now()); roomPlayerRepository.save(creatorPlayer); log.info("[房间管理] 创建房间, roomId={}, gameId={}, creatorId={}", room.getId(), request.gameId(), creatorId); // 通知大厅 RoomItemResponse response = buildRoomItemResponse(room); sseEventPublisher.broadcast(SseEventType.ROOM_CREATED, response); return new CreateRoomResponse(response); } /** * 加入房间 * * @param request 加入房间请求 * @param userId 用户 accountId * @return 房间信息 */ public RoomItemResponse joinRoom(JoinRoomRequest request, String userId) { GameRoomEntity room = gameRoomRepository.findById(request.roomId()) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); if (!"waiting".equals(room.getStatus())) { throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); } // 密码校验(明文比对) if (room.getPassword() != null && !room.getPassword().isBlank()) { if (request.password() == null || request.password().isBlank()) { throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR, "请输入房间密码"); } if (!room.getPassword().equals(request.password())) { throw new BusinessException(ErrorCode.ROOM_PASSWORD_ERROR); } } // 检查房间是否已满 int currentCount = roomPlayerRepository.countByRoomId(room.getId()); if (currentCount >= room.getMaxPlayers()) { throw new BusinessException(ErrorCode.ROOM_FULL); } // 检查用户是否已在其他房间 if (roomPlayerRepository.existsByUserId(userId)) { throw new BusinessException(ErrorCode.ALREADY_IN_ROOM); } // 分配座位号 List existing = roomPlayerRepository.findByRoomId(room.getId()); int nextSeat = 1; if (!existing.isEmpty()) { int maxSeat = existing.stream().mapToInt(RoomPlayerEntity::getSeatNumber).max().orElse(0); nextSeat = maxSeat + 1; } RoomPlayerEntity player = new RoomPlayerEntity() .setRoomId(room.getId()) .setUserId(userId) .setSeatNumber(nextSeat) .setReadyStatus(false) .setJoinTime(LocalDateTime.now()); roomPlayerRepository.save(player); log.info("[房间管理] 玩家加入, roomId={}, userId={}, seat={}", room.getId(), userId, nextSeat); // WS 广播玩家加入 RoomPlayerDetail detail = buildPlayerDetail(userId, nextSeat, false); broadcastToRoom(room.getId(), new WsMessage(WsOutboundMessage.PLAYER_JOIN, detail)); // SSE 通知大厅 RoomItemResponse response = buildRoomItemResponse(room); sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); return response; } /** * 离开房间 * * @param roomId 房间 ID * @param userId 用户 accountId */ public void leaveRoom(String roomId, String userId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); // 如果游戏进行中,通知引擎玩家离开(视为认输) RoomRuntime runtime = roomRuntimes.get(roomId); if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) { runtime.gameEngine.onPlayerLeave(userId); } // 取消断线倒计时 cancelDisconnectTimer(roomId, userId); roomPlayerRepository.delete(player); List remaining = roomPlayerRepository.findByRoomId(roomId); if (remaining.isEmpty()) { // 无人了,清理并删除房间 cleanupRoom(roomId); gameRoomRepository.delete(room); log.info("[房间管理] 房间无人,已删除, roomId={}", roomId); sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); } else if (userId.equals(room.getCreatorId())) { // 房主离开,顺位转让 RoomPlayerEntity nextOwner = remaining.get(0); room.setCreatorId(nextOwner.getUserId()); nextOwner.setReadyStatus(true); roomPlayerRepository.save(nextOwner); gameRoomRepository.save(room); log.info("[房间管理] 房主转让, roomId={}, newOwner={}", roomId, nextOwner.getUserId()); // 通知更新 broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); pushRoomUpdatedSse(room); } else { broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); pushRoomUpdatedSse(room); } log.info("[房间管理] 玩家离开, roomId={}, userId={}", roomId, userId); } /** * 解散房间(仅房主) * * @param roomId 房间 ID * @param ownerId 房主 accountId */ public void disbandRoom(String roomId, String ownerId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); if (!ownerId.equals(room.getCreatorId())) { throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); } // 如果游戏进行中,销毁引擎 RoomRuntime runtime = roomRuntimes.get(roomId); if (runtime != null && runtime.gameEngine != null) { runtime.gameEngine.destroy(); } // 通知房间内所有人被踢 broadcastToRoom(roomId, new WsMessage("room_disbanded", Map.of("roomId", roomId))); // 关闭房间内所有 WS 连接 for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) { try { ws.close(); } catch (Exception ignored) {} } // 清理并删除 cleanupRoom(roomId); roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(roomId)); gameRoomRepository.delete(room); log.info("[房间管理] 房间已解散, roomId={}", roomId); sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); } /** * 踢出玩家(仅房主) * * @param roomId 房间 ID * @param ownerId 房主 accountId * @param targetUserId 目标用户 accountId */ public void kickPlayer(String roomId, String ownerId, String targetUserId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); if (!ownerId.equals(room.getCreatorId())) { throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); } if (ownerId.equals(targetUserId)) { throw new BusinessException(ErrorCode.BAD_REQUEST, "不能踢出自己,请使用离开房间"); } RoomPlayerEntity target = roomPlayerRepository.findByRoomIdAndUserId(roomId, targetUserId) .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); // 如果游戏进行中,通知引擎 RoomRuntime runtime = roomRuntimes.get(roomId); if ("playing".equals(room.getStatus()) && runtime != null && runtime.gameEngine != null) { runtime.gameEngine.onPlayerLeave(targetUserId); } cancelDisconnectTimer(roomId, targetUserId); roomPlayerRepository.delete(target); log.info("[房间管理] 踢出玩家, roomId={}, targetUserId={}", roomId, targetUserId); // WS 通知 broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", targetUserId))); // SSE 通知被踢者 sseEventPublisher.sendTo(targetUserId, SseEventType.KICKED, Map.of("roomId", roomId)); // 大厅更新 pushRoomUpdatedSse(room); } /** * 切换准备状态 * * @param roomId 房间 ID * @param userId 用户 accountId * @return 新的准备状态 */ public boolean toggleReady(String roomId, String userId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); if (!"waiting".equals(room.getStatus())) { throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); } RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.PLAYER_NOT_IN_ROOM)); boolean newStatus = !player.getReadyStatus(); player.setReadyStatus(newStatus); roomPlayerRepository.save(player); log.info("[房间管理] 准备状态变更, roomId={}, userId={}, ready={}", roomId, userId, newStatus); // WS 广播 broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.READY_CHANGE, Map.of( "userId", userId, "ready", newStatus ))); // SSE 大厅更新 pushRoomUpdatedSse(room); return newStatus; } /** * 开始游戏(仅房主) * * @param roomId 房间 ID * @param userId 操作者 accountId(必须是房主) */ public void startGame(String roomId, String userId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); if (!userId.equals(room.getCreatorId())) { throw new BusinessException(ErrorCode.NOT_ROOM_OWNER); } if (!"waiting".equals(room.getStatus())) { throw new BusinessException(ErrorCode.ROOM_ALREADY_STARTED); } List players = roomPlayerRepository.findByRoomId(roomId); if (players.size() < 2) { throw new BusinessException(ErrorCode.PLAYER_COUNT_INSUFFICIENT); } for (RoomPlayerEntity p : players) { if (!p.getReadyStatus()) { throw new BusinessException(ErrorCode.NOT_ALL_READY); } } // 检查引擎是否已注册 if (!engineRegistry.isRegistered(room.getGameId())) { throw new BusinessException(ErrorCode.BAD_REQUEST, "该游戏尚未实现引擎: " + room.getGameId()); } // 更新房间状态 room.setStatus("playing"); gameRoomRepository.save(room); // 创建游戏记录 GameRecordEntity record = new GameRecordEntity() .setRoomId(roomId) .setStartedAt(LocalDateTime.now()); gameRecordRepository.save(record); // 构建房间上下文 List sortedPlayers = players.stream() .sorted(Comparator.comparingInt(RoomPlayerEntity::getSeatNumber)) .toList(); List playerInfos = sortedPlayers.stream() .map(p -> new RoomContext.PlayerInfo(p.getUserId(), p.getSeatNumber())) .toList(); RoomContext context = new RoomContext( roomId, room.getGameId(), playerInfos, room.getMode(), room.getStakes(), msg -> broadcastToRoom(roomId, msg), (targetUserId, msg) -> sendToUser(roomId, targetUserId, msg), (winnerId, resultType) -> onGameOver(roomId, winnerId, resultType) ); // 创建并初始化游戏引擎 GameEngine engine = engineRegistry.create(room.getGameId(), context); engine.init(context); // 缓存运行时 RoomRuntime runtime = roomRuntimes.computeIfAbsent(roomId, k -> new RoomRuntime()); runtime.gameEngine = engine; log.info("[房间管理] 游戏开始, roomId={}, gameId={}, 玩家数={}", roomId, room.getGameId(), players.size()); // SSE 大厅更新 pushRoomUpdatedSse(room); } // ==================== 断线重连 ==================== /** * 处理 WebSocket 断线 * * 启动 60 秒重连倒计时,超时后根据房间状态处理: * - WAITING:自动离开房间 * - PLAYING:通知 GameEngine(默认处理:自动认输后离开) * * @param roomId 房间 ID * @param userId 断线用户 accountId */ public void handleDisconnect(String roomId, String userId) { RoomRuntime runtime = roomRuntimes.get(roomId); if (runtime == null) { return; } // 避免重复启动定时器 if (runtime.disconnectTimers.containsKey(userId)) { return; } String taskKey = roomId + ":" + userId; log.info("[房间管理] 玩家断线,启动{}秒重连倒计时, roomId={}, userId={}", DISCONNECT_TIMEOUT_SECONDS, roomId, userId); ScheduledFuture task = reconnectScheduler.schedule(() -> { runtime.disconnectTimers.remove(userId); try { onDisconnectTimeout(roomId, userId); } catch (Exception e) { log.error("[房间管理] 断线超时处理异常, roomId={}, userId={}", roomId, userId, e); } }, DISCONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS); runtime.disconnectTimers.put(userId, task); // 通知游戏引擎 if (runtime.gameEngine != null) { runtime.gameEngine.onPlayerDisconnect(userId); } // WS 广播断线 broadcastToRoom(roomId, new WsMessage("player_disconnected", Map.of("userId", userId))); } /** * 处理 WebSocket 重连 * * 取消断线倒计时,推送完整状态给重连玩家。 * * @param roomId 房间 ID * @param userId 重连用户 accountId * @return 完整房间状态快照,用于前端恢复 */ public RoomDetailResponse handleReconnect(String roomId, String userId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElse(null); if (room == null) { return null; } // 取消倒计时 cancelDisconnectTimer(roomId, userId); // 通知游戏引擎 RoomRuntime runtime = roomRuntimes.get(roomId); if (runtime != null && runtime.gameEngine != null) { runtime.gameEngine.onPlayerReconnect(userId); } // WS 广播重连 broadcastToRoom(roomId, new WsMessage("player_reconnected", Map.of("userId", userId))); log.info("[房间管理] 玩家重连, roomId={}, userId={}", roomId, userId); // 返回完整房间详情 return buildRoomDetail(room); } /** * 取消断线倒计时 */ private void cancelDisconnectTimer(String roomId, String userId) { RoomRuntime runtime = roomRuntimes.get(roomId); if (runtime == null) return; ScheduledFuture task = runtime.disconnectTimers.remove(userId); if (task != null) { task.cancel(false); log.info("[房间管理] 取消断线倒计时, roomId={}, userId={}", roomId, userId); } } /** * 断线超时处理 */ private void onDisconnectTimeout(String roomId, String userId) { GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); if (room == null) return; boolean stillInRoom = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).isPresent(); if (!stillInRoom) return; if ("playing".equals(room.getStatus())) { log.info("[房间管理] 断线超时:对局中自动判负, roomId={}, userId={}", roomId, userId); // 通知引擎,引擎内部处理认输 RoomRuntime runtime = roomRuntimes.get(roomId); if (runtime != null && runtime.gameEngine != null) { runtime.gameEngine.onPlayerLeave(userId); } // 从房间移除 leaveRoomSilent(roomId, userId); } else { log.info("[房间管理] 断线超时:等待中自动离开, roomId={}, userId={}", roomId, userId); leaveRoomSilent(roomId, userId); } } /** * 静默离开(不触发额外的游戏事件,超时专用) */ private void leaveRoomSilent(String roomId, String userId) { RoomPlayerEntity player = roomPlayerRepository.findByRoomIdAndUserId(roomId, userId).orElse(null); if (player == null) return; cancelDisconnectTimer(roomId, userId); roomPlayerRepository.delete(player); GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); if (room == null) return; List remaining = roomPlayerRepository.findByRoomId(roomId); if (remaining.isEmpty()) { cleanupRoom(roomId); gameRoomRepository.delete(room); sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", roomId)); } else if (userId.equals(room.getCreatorId())) { RoomPlayerEntity nextOwner = remaining.get(0); room.setCreatorId(nextOwner.getUserId()); nextOwner.setReadyStatus(true); roomPlayerRepository.save(nextOwner); gameRoomRepository.save(room); broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); pushRoomUpdatedSse(room); } else { broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.PLAYER_LEAVE, Map.of("userId", userId))); pushRoomUpdatedSse(room); } } // ==================== 游戏结束回调 ==================== /** * 游戏结束回调(由 GameEngine 通过 RoomContext 调用) * * 房间层负责: * 1. 更新房间状态为 FINISHED * 2. 保存对局记录(胜负结果) * 3. 金币结算(TODO) * 4. 广播 game_over + SSE 推送 * * @param roomId 房间 ID * @param winnerId 胜者 accountId(null 表示平局) * @param resultType 结果类型(win/draw/resign) */ public void onGameOver(String roomId, String winnerId, String resultType) { GameRoomEntity room = gameRoomRepository.findById(roomId).orElse(null); if (room == null) return; room.setStatus("finished"); gameRoomRepository.save(room); // 更新对局记录 GameRecordEntity record = gameRecordRepository.findByRoomId(roomId).orElse(null); if (record != null) { record.setWinnerId(winnerId) .setResultType(resultType) .setEndedAt(LocalDateTime.now()); gameRecordRepository.save(record); } // TODO: 金币结算 — 房间层从玩家余额扣除/增加 stakes log.info("[房间管理] 游戏结束, roomId={}, winnerId={}, resultType={}", roomId, winnerId, resultType); // WS 广播游戏结束 broadcastToRoom(roomId, new WsMessage(WsOutboundMessage.GAME_OVER, Map.of( "winnerId", winnerId != null ? winnerId : "", "resultType", resultType ))); // SSE 大厅更新 pushRoomUpdatedSse(room); // 发布事件(供其他模块订阅) eventBus.publish(RoomEventBus.GAME_OVER, new RoomEventBus.GameOverEvent(roomId, winnerId, resultType)); } /** * 执行游戏动作(委托给 GameEngine) * * 由 WebSocket Handler 调用,RoomManager 查找对应房间的活跃引擎并转发动作。 * * @param roomId 房间 ID * @param userId 操作者 accountId * @param action 动作类型 * @param data 动作数据(可为 null) */ public void executeGameAction(String roomId, String userId, String action, Object data) { RoomRuntime runtime = roomRuntimes.get(roomId); if (runtime == null || runtime.gameEngine == null) { throw new BusinessException(ErrorCode.BAD_REQUEST, "游戏尚未开始"); } // 将 data 转换为 JsonNode com.fasterxml.jackson.databind.JsonNode jsonData = null; if (data != null) { String jsonStr = JSON.toJSONString(data); try { jsonData = new com.fasterxml.jackson.databind.ObjectMapper().readTree(jsonStr); } catch (Exception e) { throw new BusinessException(ErrorCode.BAD_REQUEST, "无效的游戏数据格式"); } } runtime.gameEngine.handleAction(action, jsonData, userId); } // ==================== 查询 ==================== /** * 获取房间完整详情(进入房间时调用) */ public RoomDetailResponse buildRoomDetail(GameRoomEntity room) { if (room == null) { room = gameRoomRepository.findById(room.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); } GameConfigEntity gameConfig = gameConfigRepository.findByGameId(room.getGameId()) .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); List roomPlayers = roomPlayerRepository.findByRoomId(room.getId()); List userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList(); Map userMap = userRepository.findByAccountIdIn(userIds).stream() .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); List playerDetails = roomPlayers.stream() .map(rp -> { UserEntity user = userMap.get(rp.getUserId()); return new RoomDetailResponse.RoomPlayerDetail( rp.getUserId(), user != null ? user.getAvatar() : "", user != null ? user.getNickName() : "未知玩家", rp.getSeatNumber(), rp.getReadyStatus() ); }).toList(); String creatorName = getNickname(room.getCreatorId()); // 对局中或有对局记录时,查询棋盘状态 int[][] boardState = null; String currentTurn = null; List moves = List.of(); if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) { List moveEntities = gameMoveRepository.findByRoomIdOrderByMoveIndexAsc(room.getId()); moves = moveEntities.stream() .map(m -> new RoomDetailResponse.MoveItem(m.getMoveIndex(), m.getPlayerId(), m.getRowNum(), m.getColNum())) .toList(); // 从走棋记录重建棋盘 boardState = rebuildBoard(moveEntities, 15); } return new RoomDetailResponse( room.getId(), room.getName(), room.getGameId(), gameConfig.getIcon(), gameConfig.getName(), room.getMaxPlayers(), room.getMode(), room.getStakes(), room.getStatus(), room.getCreatorId(), creatorName, playerDetails, boardState, currentTurn, moves ); } /** 棋盘大小(五子棋默认 15) */ private static final int BOARD_SIZE = 15; /** * 从走棋记录重建棋盘 */ private int[][] rebuildBoard(List moves, int boardSize) { int[][] board = new int[boardSize][boardSize]; for (GameMoveEntity move : moves) { int player = (move.getMoveIndex() % 2 == 1) ? 1 : 2; board[move.getRowNum()][move.getColNum()] = player; } return board; } /** * 获取房间列表 */ public RoomListResponse getRoomList(String gameId, String status) { GameConfigEntity gameConfig = gameConfigRepository.findByGameId(gameId) .orElseThrow(() -> new BusinessException(ErrorCode.GAME_NOT_FOUND)); if (!gameConfig.getEnabled()) { throw new BusinessException(ErrorCode.GAME_NOT_FOUND); } List rooms; if (status != null && !status.isBlank()) { rooms = gameRoomRepository.findByGameIdAndStatus(gameId, status); } else { rooms = gameRoomRepository.findByGameId(gameId).stream() .filter(r -> !"finished".equals(r.getStatus())) .toList(); } List list = rooms.stream() .map(this::buildRoomItemResponse) .toList(); return new RoomListResponse(list); } // ==================== 定时任务 ==================== /** * 超时房间清理 * * 每 5 分钟执行一次: * - 清理 WAITING 超过 30 分钟无变化的房间 * - 清理 FINISHED 超过 5 分钟的房间 */ @Scheduled(fixedRate = 300_000) public void cleanupStaleRooms() { LocalDateTime now = LocalDateTime.now(); LocalDateTime waitingDeadline = now.minusMinutes(30); LocalDateTime finishedDeadline = now.minusMinutes(5); // 清理超时等待中的房间 List staleWaiting = gameRoomRepository .findByStatusAndUpdateTimeBefore("waiting", waitingDeadline); for (GameRoomEntity room : staleWaiting) { log.info("[房间清理] 清理超时等待房间, roomId={}", room.getId()); cleanupRoom(room.getId()); roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId())); gameRoomRepository.delete(room); sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId())); } // 清理已结束的房间 List staleFinished = gameRoomRepository .findByStatusAndUpdateTimeBefore("finished", finishedDeadline); for (GameRoomEntity room : staleFinished) { log.info("[房间清理] 清理已结束房间, roomId={}", room.getId()); cleanupRoom(room.getId()); roomPlayerRepository.deleteAll(roomPlayerRepository.findByRoomId(room.getId())); gameRoomRepository.delete(room); sseEventPublisher.broadcast(SseEventType.ROOM_REMOVED, Map.of("roomId", room.getId())); } if (!staleWaiting.isEmpty() || !staleFinished.isEmpty()) { log.info("[房间清理] 清理完成, 等待超时={}, 已结束={}", staleWaiting.size(), staleFinished.size()); } } // ==================== 私有辅助方法 ==================== /** * 清理房间运行时资源(引擎、定时器) */ private void cleanupRoom(String roomId) { RoomRuntime runtime = roomRuntimes.remove(roomId); if (runtime != null) { // 销毁游戏引擎 if (runtime.gameEngine != null) { runtime.gameEngine.destroy(); } // 取消所有断线定时器 for (ScheduledFuture timer : runtime.disconnectTimers.values()) { timer.cancel(false); } runtime.disconnectTimers.clear(); } } /** * 广播 WS 消息到房间内所有人 */ private void broadcastToRoom(String roomId, WsMessage message) { String json = JSON.toJSONString(message); for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) { if (ws.isOpen()) { try { ws.sendMessage(new TextMessage(json)); } catch (IOException e) { log.error("[WS] 广播失败, sessionId={}", ws.getId(), e); } } } } /** * 向房间内指定用户发送 WS 消息(私有消息) */ private void sendToUser(String roomId, String userId, WsMessage message) { String json = JSON.toJSONString(message); for (WebSocketSession ws : sessionManager.getRoomSessions(roomId)) { if (ws.isOpen() && userId.equals(sessionManager.getUserId(ws))) { try { ws.sendMessage(new TextMessage(json)); } catch (IOException e) { log.error("[WS] 发送私有消息失败, userId={}", userId, e); } } } } /** * 构建房间列表项响应 */ private RoomItemResponse buildRoomItemResponse(GameRoomEntity room) { List players = buildRoomPlayers(room.getId()); String creatorName = getNickname(room.getCreatorId()); String icon = gameConfigRepository.findByGameId(room.getGameId()) .map(GameConfigEntity::getIcon).orElse(""); return RoomItemResponse.from(room, players, icon, creatorName); } /** * 构建单个玩家详情 */ private RoomPlayerDetail buildPlayerDetail(String userId, int seatNumber, boolean ready) { UserEntity user = userRepository.findByAccountId(userId).orElse(null); return new RoomPlayerDetail( userId, user != null ? user.getAvatar() : "", user != null ? user.getNickName() : "未知玩家", seatNumber, ready ); } /** * 组装房间玩家列表 */ private List buildRoomPlayers(String roomId) { List roomPlayers = roomPlayerRepository.findByRoomId(roomId); if (roomPlayers.isEmpty()) return List.of(); List userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList(); List users = userRepository.findByAccountIdIn(userIds); Map userMap = users.stream() .collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u)); return roomPlayers.stream() .map(rp -> { UserEntity user = userMap.get(rp.getUserId()); String avatar = user != null ? user.getAvatar() : ""; String nickname = user != null ? user.getNickName() : "未知玩家"; return new RoomPlayerResponse(avatar, nickname); }).toList(); } /** * 获取用户昵称 */ private String getNickname(String accountId) { return userRepository.findByAccountId(accountId) .map(UserEntity::getNickName) .orElse("未知玩家"); } /** * SSE 推送房间更新 */ private void pushRoomUpdatedSse(GameRoomEntity room) { RoomItemResponse response = buildRoomItemResponse(room); sseEventPublisher.broadcast(SseEventType.ROOM_UPDATED, response); } // ==================== 公共查询方法(供 Controller 使用) ==================== /** * 获取房间详情 */ public RoomDetailResponse getRoomDetail(String roomId) { GameRoomEntity room = gameRoomRepository.findById(roomId) .orElseThrow(() -> new BusinessException(ErrorCode.ROOM_NOT_FOUND)); return buildRoomDetail(room); } }