fix: wire up spectator mode end-to-end

Watch feature was half-implemented since day one and stayed broken through
the typed-protobuf migration:

1. Server (WatchGameHandler): only pushed the WatchGameSuccessResponse ack.
   A mid-game watcher joined a blank scene because the server never told
   them who the players were or what moves had been played.
2. Client: had a Watch button that sent the request but no event bus
   handler for GAME_WATCH_SUCCESSFUL, so nothing happened visually.

Server fix:
- On successful watch, if room.status == STARTING, bootstrap the watcher
  with a synthesised GameStartingResponse (player ids, nicknames, board
  size) and replay every move in room.getMoveHistory() as individual
  GameMoveSuccessResponse messages on that channel.
- Black/white player lookup uses room.getBlackPlayerId/getWhitePlayerId
  against the clientSideMap so we don't reassign roles.
- Move replay resolves playerNickname from the same map.

Client fix:
- menu-ui.js: new GAME_WATCH_SUCCESSFUL handler flips gameState.isSpectating
  = true. The subsequent GameStartingResponse flows through the existing
  menu-scene handler (transitions to GameScene) and the existing
  game-state-service handler (populates room state, resets moves).
- Move replay events propagate through the global GAME_MOVE_SUCCESS handler
  in game-state-service before GameScene.create() runs, so GameScene's
  existing rejoin/spectate loop at create() renders every stone.
This commit is contained in:
2026-04-11 09:22:54 +07:00
parent cea36323c9
commit 42d94a2aed
2 changed files with 58 additions and 1 deletions
+10
View File
@@ -181,6 +181,16 @@ eventBus.on(ClientEventCode.ROOM_CREATE_SUCCESS, (data) => {
eventBus.on(ClientEventCode.CLIENT_EXIT, showLobby);
eventBus.on(ClientEventCode.CLIENT_KICK, showLobby);
// Spectator entry: server acknowledges with WatchGameSuccessResponse, then
// immediately follows with a GameStartingResponse + replayed GameMoveSuccess
// events. Flip the spectator flag here so the existing GAME_STARTING handlers
// (menu-scene + game-state-service) transition to GameScene without allowing
// click input. The replayed moves populate gameState.moves before GameScene
// creates, so its create() path will render all existing stones.
eventBus.on(ClientEventCode.GAME_WATCH_SUCCESSFUL, () => {
gameState.isSpectating = true;
});
// Server emits NICKNAME_SET with invalidLength=0 on first connect to prompt
// the user, and with invalidLength>0 as a rejection when a nickname submission
// fails length validation. Only treat nonzero invalidLength as an error.
@@ -2,15 +2,23 @@ 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.GameMove;
import com.miti99.caro.common.entity.Room;
import com.miti99.caro.common.enums.RoomStatus;
import com.miti99.caro.protocol.GameMoveSuccessResponse;
import com.miti99.caro.protocol.GameStartingResponse;
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;
import io.netty.channel.Channel;
public final class WatchGameHandler {
private static final int BOARD_SIZE = 15;
private WatchGameHandler() {
}
@@ -28,10 +36,49 @@ public final class WatchGameHandler {
clientSide.setRoomId(room.getId());
room.getWatcherList().add(clientSide);
ChannelUtils.push(clientSide.getChannel(), Response.newBuilder()
Channel watcherChannel = clientSide.getChannel();
ChannelUtils.push(watcherChannel, Response.newBuilder()
.setWatchGameSuccess(WatchGameSuccessResponse.newBuilder()
.setOwner(room.getRoomOwner() == null ? "" : room.getRoomOwner())
.setStatus(room.getStatus() == null ? "" : room.getStatus().toString()))
.build());
// If the game is already running, bootstrap the watcher with the current
// player pairing and replay every move so their board renders correctly.
// Without this a mid-game watcher joins a blank scene.
if (room.getStatus() == RoomStatus.STARTING) {
bootstrapWatcher(watcherChannel, room);
}
}
private static void bootstrapWatcher(Channel watcherChannel, Room room) {
ClientSide blackPlayer = room.getClientSideMap().get(room.getBlackPlayerId());
ClientSide whitePlayer = room.getClientSideMap().get(room.getWhitePlayerId());
if (blackPlayer == null || whitePlayer == null) {
return;
}
ChannelUtils.push(watcherChannel, 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 (GameMove move : room.getMoveHistory()) {
ClientSide mover = room.getClientSideMap().get(move.getPlayerId());
String moverNickname = mover != null && mover.getNickname() != null ? mover.getNickname() : "";
ChannelUtils.push(watcherChannel, Response.newBuilder()
.setGameMoveSuccess(GameMoveSuccessResponse.newBuilder()
.setRow(move.getRow())
.setCol(move.getCol())
.setPiece(move.getPiece() == null ? "" : move.getPiece().name())
.setPlayerNickname(moverNickname)
.setPlayerId(move.getPlayerId()))
.build());
}
}
}