Files
webgame-backend/src/main/java/com/webgame/webgamebackend/domain/room/RoomManager.java
2026-07-07 09:58:42 +08:00

980 lines
38 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.webgame.webgamebackend.domain.room;
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.infrastructure.persistence.entity.*;
import com.webgame.webgamebackend.infrastructure.persistence.repository.*;
import com.webgame.webgamebackend.domain.engine.*;
import com.webgame.webgamebackend.adapter.ws.RoomSessionManager;
import com.webgame.webgamebackend.adapter.ws.dto.WsMessage;
import com.webgame.webgamebackend.adapter.ws.dto.WsOutboundMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
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
@RequiredArgsConstructor
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; }
}
// ==================== 房间 CRUD ====================
/**
* 创建房间
*
* @param request 创建房间请求
* @param creatorId 房主 accountId
* @return 创建结果
*/
@Transactional
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 房间信息
*/
@Transactional
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
*/
@Transactional
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
*/
@Transactional
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
*/
@Transactional
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 新的准备状态
*/
@Transactional
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必须是房主
*/
@Transactional
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);
// 创建游戏记录resultType 初始为 pending游戏结束时由 onGameOver 更新)
GameRecordEntity record = new GameRecordEntity()
.setRoomId(roomId)
.setResultType("pending")
.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
*/
@Transactional
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);
}
}