feat: 重构游戏房间架构

This commit is contained in:
2026-06-30 10:20:26 +08:00
parent d92f7bb581
commit ff284e2841
25 changed files with 2065 additions and 1324 deletions

View File

@@ -0,0 +1,989 @@
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;
/**
* 房间管理器 — 统一管理所有房间生命周期
*
* <h3>职责</h3>
* <ul>
* <li>房间 CRUD创建、加入、离开、解散、踢人</li>
* <li>状态流转WAITING → PLAYING → FINISHED</li>
* <li>准备/取消准备</li>
* <li>开始游戏(委托给 GameEngine</li>
* <li>断线重连管理60 秒倒计时)</li>
* <li>超时房间清理(定时任务)</li>
* <li>游戏结束结算(房间层负责金币变更)</li>
* </ul>
*
* <p>房间数据持久化到 MySQL运行时状态游戏引擎引用、断线定时器
* 缓存在内存中。</p>
*/
@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<String, RoomRuntime> roomRuntimes = new ConcurrentHashMap<>();
/**
* 房间运行时状态(不可持久化的部分)
*/
private static class RoomRuntime {
/** 活跃的游戏引擎PLAYING 状态下非 null */
volatile GameEngine gameEngine;
/** 断线倒计时任务: userId → Future */
final ConcurrentHashMap<String, ScheduledFuture<?>> 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<String> modes = JSON.parseArray(gameConfig.getModes(), String.class);
if (!modes.contains(request.mode())) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "不支持的模式: " + request.mode());
}
// 校验 stakes
List<Integer> 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<RoomPlayerEntity> 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<RoomPlayerEntity> 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<RoomPlayerEntity> 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<RoomPlayerEntity> sortedPlayers = players.stream()
.sorted(Comparator.comparingInt(RoomPlayerEntity::getSeatNumber))
.toList();
List<RoomContext.PlayerInfo> 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<RoomPlayerEntity> 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 胜者 accountIdnull 表示平局)
* @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<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(room.getId());
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
Map<String, UserEntity> userMap = userRepository.findByAccountIdIn(userIds).stream()
.collect(Collectors.toMap(u -> u.getAccount().getId(), u -> u));
List<RoomDetailResponse.RoomPlayerDetail> 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<RoomDetailResponse.MoveItem> moves = List.of();
if ("playing".equals(room.getStatus()) || "finished".equals(room.getStatus())) {
List<GameMoveEntity> 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<GameMoveEntity> 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<GameRoomEntity> 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<RoomItemResponse> 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<GameRoomEntity> 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<GameRoomEntity> 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<RoomPlayerResponse> 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<RoomPlayerResponse> buildRoomPlayers(String roomId) {
List<RoomPlayerEntity> roomPlayers = roomPlayerRepository.findByRoomId(roomId);
if (roomPlayers.isEmpty()) return List.of();
List<String> userIds = roomPlayers.stream().map(RoomPlayerEntity::getUserId).toList();
List<UserEntity> users = userRepository.findByAccountIdIn(userIds);
Map<String, UserEntity> 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);
}
}