refactor(server): migrate event handlers to typed records

Replace every UnsupportedOperationException stub in RequestDispatcher with a
real handler call. All 15 handlers now live under com.miti99.caro.server.event.handler
and take a typed ClientRequest record, emitting typed Response protos via
ChannelUtils.push.

Handlers ported:
- SetClientInfoHandler, SetNicknameHandler
- CreateRoomHandler, CreatePveRoomHandler, GetRoomsHandler, JoinRoomHandler
- GameStartingHandler, GameReadyHandler
- GameMoveHandler (full PVP + PVE AI + game-over broadcast)
- GameResetHandler (noop, never wired before)
- WatchGameHandler, WatchGameExitHandler
- ClientExitHandler, ClientOfflineHandler

WebsocketTransferHandler.clientOfflineEvent now dispatches to ClientOfflineHandler.
RoomClearTask reuses ClientExitHandler for stale-room cleanup.

All 37 unit tests pass.
This commit is contained in:
2026-04-11 08:14:52 +07:00
parent 945a249b89
commit b75733fd2d
17 changed files with 758 additions and 23 deletions
@@ -1,6 +1,19 @@
package com.miti99.caro.server.event;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.server.event.handler.ClientExitHandler;
import com.miti99.caro.server.event.handler.CreatePveRoomHandler;
import com.miti99.caro.server.event.handler.CreateRoomHandler;
import com.miti99.caro.server.event.handler.GameMoveHandler;
import com.miti99.caro.server.event.handler.GameReadyHandler;
import com.miti99.caro.server.event.handler.GameResetHandler;
import com.miti99.caro.server.event.handler.GameStartingHandler;
import com.miti99.caro.server.event.handler.GetRoomsHandler;
import com.miti99.caro.server.event.handler.JoinRoomHandler;
import com.miti99.caro.server.event.handler.SetClientInfoHandler;
import com.miti99.caro.server.event.handler.SetNicknameHandler;
import com.miti99.caro.server.event.handler.WatchGameExitHandler;
import com.miti99.caro.server.event.handler.WatchGameHandler;
import com.miti99.caro.server.event.request.ClientExitRequestRecord;
import com.miti99.caro.server.event.request.ClientRequest;
import com.miti99.caro.server.event.request.CreatePveRoomRequestRecord;
@@ -20,9 +33,6 @@ import com.miti99.caro.server.event.request.WatchGameRequestRecord;
/**
* Routes a {@link ClientRequest} record to its handler via exhaustive pattern
* matching. Replaces the old reflection-based {@code ServerEventListener} lookup.
*
* <p>Phase 02a: only {@code HeartbeatRequestRecord} has a real no-op implementation;
* every other case throws {@link UnsupportedOperationException} pending Phase 02b.
*/
public final class RequestDispatcher {
@@ -34,23 +44,19 @@ public final class RequestDispatcher {
case HeartbeatRequestRecord r -> {
/* no-op: heartbeat just refreshes IdleStateHandler */
}
case SetNicknameRequestRecord r -> throw todo("set_nickname");
case SetClientInfoRequestRecord r -> throw todo("set_client_info");
case CreateRoomRequestRecord r -> throw todo("create_room");
case CreatePveRoomRequestRecord r -> throw todo("create_pve_room");
case GetRoomsRequestRecord r -> throw todo("get_rooms");
case JoinRoomRequestRecord r -> throw todo("join_room");
case GameStartingRequestRecord r -> throw todo("game_starting");
case GameReadyRequestRecord r -> throw todo("game_ready");
case GameMoveRequestRecord r -> throw todo("game_move");
case GameResetRequestRecord r -> throw todo("game_reset");
case WatchGameRequestRecord r -> throw todo("watch_game");
case WatchGameExitRequestRecord r -> throw todo("watch_game_exit");
case ClientExitRequestRecord r -> throw todo("client_exit");
case SetNicknameRequestRecord r -> SetNicknameHandler.handle(client, r);
case SetClientInfoRequestRecord r -> SetClientInfoHandler.handle(client, r);
case CreateRoomRequestRecord r -> CreateRoomHandler.handle(client, r);
case CreatePveRoomRequestRecord r -> CreatePveRoomHandler.handle(client, r);
case GetRoomsRequestRecord r -> GetRoomsHandler.handle(client, r);
case JoinRoomRequestRecord r -> JoinRoomHandler.handle(client, r);
case GameStartingRequestRecord r -> GameStartingHandler.handle(client, r);
case GameReadyRequestRecord r -> GameReadyHandler.handle(client, r);
case GameMoveRequestRecord r -> GameMoveHandler.handle(client, r);
case GameResetRequestRecord r -> GameResetHandler.handle(client, r);
case WatchGameRequestRecord r -> WatchGameHandler.handle(client, r);
case WatchGameExitRequestRecord r -> WatchGameExitHandler.handle(client, r);
case ClientExitRequestRecord r -> ClientExitHandler.handle(client, r);
}
}
private static UnsupportedOperationException todo(String name) {
return new UnsupportedOperationException("TODO phase 02b: " + name);
}
}
@@ -0,0 +1,46 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.protocol.ClientExitResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.ClientExitRequestRecord;
public final class ClientExitHandler {
private static final Object LOCK = new Object();
private ClientExitHandler() {
}
public static void handle(ClientSide clientSide, ClientExitRequestRecord req) {
synchronized (LOCK) {
Room room = ServerContains.getRoom(clientSide.getRoomId());
if (room == null) {
return;
}
Response notice = Response.newBuilder()
.setClientExit(ClientExitResponse.newBuilder()
.setRoomId(room.getId())
.setExitClientId(clientSide.getId())
.setExitClientNickname(clientSide.getNickname() == null ? "" : clientSide.getNickname()))
.build();
for (ClientSide client : room.getClientSideList()) {
if (client.getChannel() != null) {
ChannelUtils.push(client.getChannel(), notice);
client.init();
}
}
for (ClientSide watcher : room.getWatcherList()) {
ChannelUtils.push(watcher.getChannel(), notice);
}
ServerContains.removeRoom(room.getId());
}
}
}
@@ -0,0 +1,52 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.protocol.ClientExitResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.server.ServerContains;
/**
* Triggered directly by the Netty pipeline on channel close or idle timeout —
* NOT routed through the {@code RequestDispatcher}. There is no wire-level
* request for this event; it is observed server-side only.
*/
public final class ClientOfflineHandler {
private ClientOfflineHandler() {
}
public static void handle(ClientSide clientSide) {
if (clientSide == null) {
return;
}
Room room = ServerContains.getRoom(clientSide.getRoomId());
if (room == null) {
ServerContains.CLIENT_SIDE_MAP.remove(clientSide.getId());
return;
}
if (room.getWatcherList().contains(clientSide)) {
room.getWatcherList().remove(clientSide);
return;
}
Response notice = Response.newBuilder()
.setClientExit(ClientExitResponse.newBuilder()
.setRoomId(room.getId())
.setExitClientId(clientSide.getId())
.setExitClientNickname(clientSide.getNickname() == null ? "" : clientSide.getNickname()))
.build();
for (ClientSide client : room.getClientSideList()) {
if (client.getChannel() != null && client.getId() != clientSide.getId()) {
ChannelUtils.push(client.getChannel(), notice);
client.init();
}
}
ServerContains.removeRoom(room.getId());
}
}
@@ -0,0 +1,66 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.ClientRole;
import com.miti99.caro.common.enums.ClientStatus;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.common.enums.RoomType;
import com.miti99.caro.protocol.PveDifficultyNotSupportResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.CreatePveRoomRequestRecord;
import com.miti99.caro.server.event.request.GameStartingRequestRecord;
public final class CreatePveRoomHandler {
private CreatePveRoomHandler() {
}
public static void handle(ClientSide clientSide, CreatePveRoomRequestRecord req) {
int difficulty = req.difficulty();
if (difficulty < 1 || difficulty > 3) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setPveDifficultyNotSupport(PveDifficultyNotSupportResponse.getDefaultInstance())
.build());
return;
}
Room room = new Room(ServerContains.getServerId());
room.setType(RoomType.PVE);
room.setStatus(RoomStatus.WAIT);
room.setRoomOwner(clientSide.getNickname());
room.setDifficultyCoefficient(difficulty);
room.setCreateTime(System.currentTimeMillis());
room.setLastFlushTime(System.currentTimeMillis());
// Add human player
room.getClientSideMap().put(clientSide.getId(), clientSide);
room.getClientSideList().add(clientSide);
clientSide.setRoomId(room.getId());
clientSide.setRole(ClientRole.BLACK_PLAYER);
// Add AI robot (1 robot for 2-player Gomoku)
ClientSide robot = new ClientSide(-ServerContains.getClientId(), ClientStatus.PLAYING, null);
robot.setNickname("AI_" + getDifficultyName(difficulty));
robot.setRole(ClientRole.WHITE_PLAYER);
robot.setRoomId(room.getId());
room.getClientSideMap().put(robot.getId(), robot);
room.getClientSideList().add(robot);
ServerContains.CLIENT_SIDE_MAP.put(robot.getId(), robot);
ServerContains.addRoom(room);
// Auto-start game
GameStartingHandler.handle(clientSide, new GameStartingRequestRecord());
}
private static String getDifficultyName(int difficulty) {
return switch (difficulty) {
case 2 -> "Medium";
case 3 -> "Hard";
default -> "Easy";
};
}
}
@@ -0,0 +1,43 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.ClientRole;
import com.miti99.caro.common.enums.ClientStatus;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.common.enums.RoomType;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.protocol.RoomCreateSuccessResponse;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.CreateRoomRequestRecord;
public final class CreateRoomHandler {
private CreateRoomHandler() {
}
public static void handle(ClientSide clientSide, CreateRoomRequestRecord req) {
Room room = new Room(ServerContains.getServerId());
room.setStatus(RoomStatus.WAIT);
room.setType(RoomType.PVP);
room.setRoomOwner(clientSide.getNickname());
room.getClientSideMap().put(clientSide.getId(), clientSide);
room.getClientSideList().add(clientSide);
room.setCreateTime(System.currentTimeMillis());
room.setLastFlushTime(System.currentTimeMillis());
clientSide.setRoomId(room.getId());
clientSide.setRole(ClientRole.BLACK_PLAYER);
clientSide.setStatus(ClientStatus.NO_READY);
ServerContains.addRoom(room);
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setRoomCreateSuccess(RoomCreateSuccessResponse.newBuilder()
.setId(room.getId())
.setRoomOwner(room.getRoomOwner())
.setRoomType(room.getType().name()))
.build());
}
}
@@ -0,0 +1,146 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.Board;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.GameMove;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.GameResult;
import com.miti99.caro.common.enums.PieceType;
import com.miti99.caro.common.enums.RoomType;
import com.miti99.caro.common.helper.GomokuHelper;
import com.miti99.caro.common.robot.GomokuAI;
import com.miti99.caro.protocol.GameMoveNotYourTurnResponse;
import com.miti99.caro.protocol.GameMoveOccupiedResponse;
import com.miti99.caro.protocol.GameMoveOutOfBoundsResponse;
import com.miti99.caro.protocol.GameMoveSuccessResponse;
import com.miti99.caro.protocol.GameOverResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.protocol.RoomPlayFailNotFoundResponse;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.GameMoveRequestRecord;
public final class GameMoveHandler {
private GameMoveHandler() {
}
public static void handle(ClientSide clientSide, GameMoveRequestRecord req) {
Room room = ServerContains.getRoom(clientSide.getRoomId());
if (room == null) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setRoomPlayFailNotFound(RoomPlayFailNotFoundResponse.getDefaultInstance())
.build());
return;
}
int row = req.row();
int col = req.col();
if (!room.isPlayerTurn(clientSide.getId())) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setGameMoveNotYourTurn(GameMoveNotYourTurnResponse.getDefaultInstance())
.build());
return;
}
Board board = room.getGameBoard();
if (row < 0 || row >= Board.BOARD_SIZE || col < 0 || col >= Board.BOARD_SIZE) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setGameMoveOutOfBounds(GameMoveOutOfBoundsResponse.getDefaultInstance())
.build());
return;
}
if (board.getPiece(row, col) != PieceType.EMPTY) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setGameMoveOccupied(GameMoveOccupiedResponse.getDefaultInstance())
.build());
return;
}
GameResult result = GomokuHelper.makeMove(room, row, col, clientSide.getId());
broadcastToRoom(room, Response.newBuilder()
.setGameMoveSuccess(GameMoveSuccessResponse.newBuilder()
.setRow(row)
.setCol(col)
.setPiece(room.getPlayerPiece(clientSide.getId()).name())
.setPlayerNickname(clientSide.getNickname() == null ? "" : clientSide.getNickname())
.setPlayerId(clientSide.getId()))
.build());
if (result != GameResult.IN_PROGRESS) {
handleGameOver(room, result);
return;
}
if (room.getType() == RoomType.PVE) {
handleAIMove(room);
}
}
private static void handleAIMove(Room room) {
ClientSide aiPlayer = null;
for (ClientSide client : room.getClientSideList()) {
if (client.getChannel() == null) {
aiPlayer = client;
break;
}
}
if (aiPlayer == null || !room.isPlayerTurn(aiPlayer.getId())) {
return;
}
PieceType aiPiece = room.getPlayerPiece(aiPlayer.getId());
GomokuAI ai = new GomokuAI(aiPiece);
GameMove aiMove = ai.getNextMove(room.getGameBoard(), room.getDifficultyCoefficient());
if (aiMove == null) {
return;
}
GameResult result = GomokuHelper.makeMove(room, aiMove.getRow(), aiMove.getCol(), aiPlayer.getId());
broadcastToRoom(room, Response.newBuilder()
.setGameMoveSuccess(GameMoveSuccessResponse.newBuilder()
.setRow(aiMove.getRow())
.setCol(aiMove.getCol())
.setPiece(aiPiece.name())
.setPlayerNickname(aiPlayer.getNickname() == null ? "" : aiPlayer.getNickname())
.setPlayerId(aiPlayer.getId()))
.build());
if (result != GameResult.IN_PROGRESS) {
handleGameOver(room, result);
}
}
private static void handleGameOver(Room room, GameResult result) {
String winnerNickname = "";
if (result == GameResult.BLACK_WIN) {
ClientSide winner = room.getClientSideMap().get(room.getBlackPlayerId());
winnerNickname = winner != null ? winner.getNickname() : "Black";
} else if (result == GameResult.WHITE_WIN) {
ClientSide winner = room.getClientSideMap().get(room.getWhitePlayerId());
winnerNickname = winner != null ? winner.getNickname() : "White";
}
broadcastToRoom(room, Response.newBuilder()
.setGameOver(GameOverResponse.newBuilder()
.setResult(result.name())
.setWinnerNickname(winnerNickname == null ? "" : winnerNickname))
.build());
}
private static void broadcastToRoom(Room room, Response response) {
for (ClientSide client : room.getClientSideList()) {
if (client.getChannel() != null) {
ChannelUtils.push(client.getChannel(), response);
}
}
for (ClientSide watcher : room.getWatcherList()) {
ChannelUtils.push(watcher.getChannel(), response);
}
}
}
@@ -0,0 +1,57 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.ClientStatus;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.protocol.GameReadyResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.GameReadyRequestRecord;
import com.miti99.caro.server.event.request.GameStartingRequestRecord;
public final class GameReadyHandler {
private GameReadyHandler() {
}
public static void handle(ClientSide clientSide, GameReadyRequestRecord req) {
Room room = ServerContains.getRoom(clientSide.getRoomId());
if (room == null || room.getStatus() == RoomStatus.STARTING) {
return;
}
if (clientSide.getStatus() == ClientStatus.PLAYING) {
return;
}
// Toggle ready state
clientSide.setStatus(clientSide.getStatus() == ClientStatus.READY ? ClientStatus.NO_READY : ClientStatus.READY);
Response response = Response.newBuilder()
.setGameReady(GameReadyResponse.newBuilder()
.setClientNickname(clientSide.getNickname() == null ? "" : clientSide.getNickname())
.setStatus(clientSide.getStatus().name())
.setClientId(clientSide.getId()))
.build();
// Check if all human players are ready (need 2 players)
boolean allReady = room.getClientSideMap().size() >= 2;
for (ClientSide client : room.getClientSideList()) {
if (client.getChannel() != null && client.getStatus() != ClientStatus.READY) {
allReady = false;
}
}
// Notify all human players
for (ClientSide client : room.getClientSideList()) {
if (client.getChannel() != null) {
ChannelUtils.push(client.getChannel(), response);
}
}
if (allReady) {
GameStartingHandler.handle(clientSide, new GameStartingRequestRecord());
}
}
}
@@ -0,0 +1,19 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.server.event.request.GameResetRequestRecord;
/**
* Placeholder for the {@code game_reset} request. The original server never
* implemented this listener — see the audit in the migration plan. Left as a
* no-op so the sealed dispatch remains exhaustive.
*/
public final class GameResetHandler {
private GameResetHandler() {
}
public static void handle(ClientSide client, GameResetRequestRecord req) {
// Not implemented — intentional no-op.
}
}
@@ -0,0 +1,70 @@
package com.miti99.caro.server.event.handler;
import java.util.LinkedList;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.ClientRole;
import com.miti99.caro.common.enums.ClientStatus;
import com.miti99.caro.common.enums.PieceType;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.protocol.GameStartingResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.GameStartingRequestRecord;
public final class GameStartingHandler {
private static final int BOARD_SIZE = 15;
private GameStartingHandler() {
}
public static void handle(ClientSide clientSide, GameStartingRequestRecord req) {
Room room = ServerContains.getRoom(clientSide.getRoomId());
if (room == null) {
return;
}
LinkedList<ClientSide> roomClientList = room.getClientSideList();
if (roomClientList.size() < 2) {
return;
}
ClientSide blackPlayer = roomClientList.get(0);
ClientSide whitePlayer = roomClientList.get(1);
blackPlayer.setRole(ClientRole.BLACK_PLAYER);
whitePlayer.setRole(ClientRole.WHITE_PLAYER);
blackPlayer.setStatus(ClientStatus.PLAYING);
whitePlayer.setStatus(ClientStatus.PLAYING);
room.setBlackPlayerId(blackPlayer.getId());
room.setWhitePlayerId(whitePlayer.getId());
room.setCurrentTurn(PieceType.BLACK);
room.setStatus(RoomStatus.STARTING);
room.setCreateTime(System.currentTimeMillis());
room.setLastFlushTime(System.currentTimeMillis());
room.resetGame();
Response response = Response.newBuilder()
.setGameStarting(GameStartingResponse.newBuilder()
.setRoomId(room.getId())
.setBlackPlayerId(blackPlayer.getId())
.setBlackPlayerNickname(blackPlayer.getNickname() == null ? "" : blackPlayer.getNickname())
.setWhitePlayerId(whitePlayer.getId())
.setWhitePlayerNickname(whitePlayer.getNickname() == null ? "" : whitePlayer.getNickname())
.setBoardSize(BOARD_SIZE))
.build();
for (ClientSide client : roomClientList) {
if (client.getChannel() != null) {
ChannelUtils.push(client.getChannel(), response);
}
}
for (ClientSide watcher : room.getWatcherList()) {
ChannelUtils.push(watcher.getChannel(), response);
}
}
}
@@ -0,0 +1,31 @@
package com.miti99.caro.server.event.handler;
import java.util.Map;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.protocol.RoomSummary;
import com.miti99.caro.protocol.ShowRoomsResponse;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.GetRoomsRequestRecord;
public final class GetRoomsHandler {
private GetRoomsHandler() {
}
public static void handle(ClientSide client, GetRoomsRequestRecord req) {
ShowRoomsResponse.Builder response = ShowRoomsResponse.newBuilder();
for (Map.Entry<Integer, Room> entry : ServerContains.getRoomMap().entrySet()) {
Room room = entry.getValue();
response.addRooms(RoomSummary.newBuilder()
.setRoomId(room.getId())
.setRoomOwner(room.getRoomOwner() == null ? "" : room.getRoomOwner())
.setRoomClientCount(room.getClientSideList().size())
.setRoomType(room.getType() == null ? "" : room.getType().name()));
}
ChannelUtils.push(client.getChannel(), Response.newBuilder().setShowRooms(response).build());
}
}
@@ -0,0 +1,78 @@
package com.miti99.caro.server.event.handler;
import java.util.LinkedList;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.ClientStatus;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.protocol.RoomJoinFailFullResponse;
import com.miti99.caro.protocol.RoomJoinFailNotFoundResponse;
import com.miti99.caro.protocol.RoomJoinSuccessResponse;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.GameStartingRequestRecord;
import com.miti99.caro.server.event.request.JoinRoomRequestRecord;
public final class JoinRoomHandler {
private JoinRoomHandler() {
}
public static void handle(ClientSide clientSide, JoinRoomRequestRecord req) {
int roomId = req.roomId();
Room room = ServerContains.getRoom(roomId);
if (room == null) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setRoomJoinFailNotFound(RoomJoinFailNotFoundResponse.newBuilder().setRoomId(roomId))
.build());
return;
}
// Gomoku is 2-player
if (room.getClientSideList().size() >= 2) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setRoomJoinFailFull(RoomJoinFailFullResponse.newBuilder()
.setRoomId(room.getId())
.setRoomOwner(room.getRoomOwner() == null ? "" : room.getRoomOwner()))
.build());
return;
}
clientSide.setStatus(ClientStatus.READY);
clientSide.setRoomId(room.getId());
LinkedList<ClientSide> roomClientList = room.getClientSideList();
roomClientList.add(clientSide);
room.getClientSideMap().put(clientSide.getId(), clientSide);
room.setStatus(RoomStatus.WAIT);
Response joinSuccess = Response.newBuilder()
.setRoomJoinSuccess(RoomJoinSuccessResponse.newBuilder()
.setClientId(clientSide.getId())
.setClientNickname(clientSide.getNickname() == null ? "" : clientSide.getNickname())
.setRoomId(room.getId())
.setRoomOwner(room.getRoomOwner() == null ? "" : room.getRoomOwner())
.setRoomClientCount(roomClientList.size()))
.build();
for (ClientSide client : room.getClientSideMap().values()) {
if (client.getChannel() != null) {
ChannelUtils.push(client.getChannel(), joinSuccess);
}
}
// Auto-start when 2 players joined
if (roomClientList.size() == 2) {
GameStartingHandler.handle(clientSide, new GameStartingRequestRecord());
return;
}
// Notify spectators (legacy behaviour: same typed message, watcher receives join notice)
for (ClientSide watcher : room.getWatcherList()) {
ChannelUtils.push(watcher.getChannel(), joinSuccess);
}
}
}
@@ -0,0 +1,21 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.server.event.request.SetClientInfoRequestRecord;
public final class SetClientInfoHandler {
private static final String DEFAULT_VERSION = "v1.2.8";
private SetClientInfoHandler() {
}
public static void handle(ClientSide client, SetClientInfoRequestRecord req) {
String version = req.version();
if (version == null || version.isEmpty()) {
client.setVersion(DEFAULT_VERSION);
} else {
client.setVersion(version);
}
}
}
@@ -0,0 +1,31 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.protocol.NicknameSetResponse;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.protocol.ShowOptionsResponse;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.SetNicknameRequestRecord;
public final class SetNicknameHandler {
public static final int NICKNAME_MAX_LENGTH = 10;
private SetNicknameHandler() {
}
public static void handle(ClientSide client, SetNicknameRequestRecord req) {
String nickname = req.nickname() == null ? "" : req.nickname().trim();
if (nickname.isEmpty() || nickname.length() > NICKNAME_MAX_LENGTH) {
ChannelUtils.push(client.getChannel(), Response.newBuilder()
.setNicknameSet(NicknameSetResponse.newBuilder().setInvalidLength(nickname.length()))
.build());
return;
}
ServerContains.CLIENT_SIDE_MAP.get(client.getId()).setNickname(nickname);
ChannelUtils.push(client.getChannel(), Response.newBuilder()
.setShowOptions(ShowOptionsResponse.getDefaultInstance())
.build());
}
}
@@ -0,0 +1,24 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.print.SimplePrinter;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.WatchGameExitRequestRecord;
public final class WatchGameExitHandler {
private WatchGameExitHandler() {
}
public static void handle(ClientSide clientSide, WatchGameExitRequestRecord req) {
Room room = ServerContains.getRoom(clientSide.getRoomId());
if (room != null) {
clientSide.setRoomId(room.getId());
boolean removed = room.getWatcherList().remove(clientSide);
if (removed) {
SimplePrinter.serverLog(clientSide.getNickname() + " exit room " + room.getId());
}
}
}
}
@@ -0,0 +1,37 @@
package com.miti99.caro.server.event.handler;
import com.miti99.caro.common.channel.ChannelUtils;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.protocol.Response;
import com.miti99.caro.protocol.RoomJoinFailNotFoundResponse;
import com.miti99.caro.protocol.WatchGameSuccessResponse;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.request.WatchGameRequestRecord;
public final class WatchGameHandler {
private WatchGameHandler() {
}
public static void handle(ClientSide clientSide, WatchGameRequestRecord req) {
int roomId = req.roomId();
Room room = ServerContains.getRoom(roomId);
if (room == null) {
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setRoomJoinFailNotFound(RoomJoinFailNotFoundResponse.newBuilder().setRoomId(roomId))
.build());
return;
}
clientSide.setRoomId(room.getId());
room.getWatcherList().add(clientSide);
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
.setWatchGameSuccess(WatchGameSuccessResponse.newBuilder()
.setOwner(room.getRoomOwner() == null ? "" : room.getRoomOwner())
.setStatus(room.getStatus() == null ? "" : room.getStatus().toString()))
.build());
}
}
@@ -13,6 +13,7 @@ import com.miti99.caro.protocol.Response;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.RequestConverter;
import com.miti99.caro.server.event.RequestDispatcher;
import com.miti99.caro.server.event.handler.ClientOfflineHandler;
import com.miti99.caro.server.event.request.ClientRequest;
import com.miti99.caro.server.event.request.HeartbeatRequestRecord;
@@ -107,7 +108,7 @@ public class WebsocketTransferHandler extends SimpleChannelInboundHandler<Binary
ClientSide client = ServerContains.CLIENT_SIDE_MAP.get(clientId);
if (client != null) {
SimplePrinter.serverLog("Has client exit to the server: " + clientId + " | " + client.getNickname());
// TODO phase 02b: wire to ClientOfflineHandler.handle(client)
ClientOfflineHandler.handle(client);
}
}
}
@@ -3,10 +3,13 @@ package com.miti99.caro.server.timer;
import java.util.Map;
import java.util.TimerTask;
import com.miti99.caro.common.entity.ClientSide;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.common.print.SimplePrinter;
import com.miti99.caro.server.ServerContains;
import com.miti99.caro.server.event.handler.ClientExitHandler;
import com.miti99.caro.server.event.request.ClientExitRequestRecord;
/**
* Periodically cleans up idle or expired rooms.
@@ -52,7 +55,11 @@ public class RoomClearTask extends TimerTask {
}
private void closeRoom(Room room) {
// TODO phase 02b: notify remaining clients via ClientExitHandler before removal.
ServerContains.removeRoom(room.getId());
if (!room.getClientSideList().isEmpty()) {
ClientSide first = room.getClientSideList().get(0);
ClientExitHandler.handle(first, new ClientExitRequestRecord());
} else {
ServerContains.removeRoom(room.getId());
}
}
}