980 lines
38 KiB
Java
980 lines
38 KiB
Java
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 胜者 accountId(null 表示平局)
|
||
* @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);
|
||
}
|
||
}
|