From fbf35048ae56a9720fd95aa1385394ec3fc44bc0 Mon Sep 17 00:00:00 2001 From: halfdimension Date: Sun, 21 Jun 2026 16:36:13 +0530 Subject: [PATCH 1/2] Add multiplayer room lifecycle backend --- .../api/auth/security/SecurityConfig.java | 1 + .../api/exception/GlobalExceptionHandler.java | 42 +++ .../controller/MultiplayerRoomController.java | 95 ++++++ .../room/dto/CreateRoomRequest.java | 11 + .../room/dto/RoomMemberResponse.java | 24 ++ .../multiplayer/room/dto/RoomResponse.java | 32 ++ .../room/exception/RoomClosedException.java | 8 + .../exception/RoomForbiddenException.java | 8 + .../room/exception/RoomNotFoundException.java | 8 + .../room/model/MultiplayerRoom.java | 104 +++++++ .../room/model/MultiplayerRoomStatus.java | 7 + .../multiplayer/room/model/RoomMember.java | 47 +++ .../room/service/MultiplayerRoomService.java | 124 ++++++++ .../room/MultiplayerRoomApiTests.java | 275 ++++++++++++++++++ 14 files changed, 786 insertions(+) create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/controller/MultiplayerRoomController.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/CreateRoomRequest.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomMemberResponse.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomResponse.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomClosedException.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomForbiddenException.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomNotFoundException.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoom.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoomStatus.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/RoomMember.java create mode 100644 backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/service/MultiplayerRoomService.java create mode 100644 backend/route-catch-api/src/test/java/com/routecatch/api/multiplayer/room/MultiplayerRoomApiTests.java diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/auth/security/SecurityConfig.java b/backend/route-catch-api/src/main/java/com/routecatch/api/auth/security/SecurityConfig.java index cac9988..4f977a5 100644 --- a/backend/route-catch-api/src/main/java/com/routecatch/api/auth/security/SecurityConfig.java +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/auth/security/SecurityConfig.java @@ -51,6 +51,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) .requestMatchers(HttpMethod.GET, "/api/auth/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/game/creatures").permitAll() .requestMatchers("/api/game/me/**").authenticated() + .requestMatchers("/api/multiplayer/rooms/**").authenticated() .requestMatchers("/api/game/sessions/**").permitAll() .requestMatchers("/api/game/leaderboard").permitAll() .requestMatchers("/api/game/players/*/stats").permitAll() diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/exception/GlobalExceptionHandler.java b/backend/route-catch-api/src/main/java/com/routecatch/api/exception/GlobalExceptionHandler.java index f9745e7..ce4ac6e 100644 --- a/backend/route-catch-api/src/main/java/com/routecatch/api/exception/GlobalExceptionHandler.java +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/exception/GlobalExceptionHandler.java @@ -20,6 +20,9 @@ import com.routecatch.api.game.exception.InvalidGameSessionStateException; import com.routecatch.api.game.exception.InvalidPlayerNameException; import com.routecatch.api.game.exception.InvalidSessionHistoryLimitException; +import com.routecatch.api.multiplayer.room.exception.RoomClosedException; +import com.routecatch.api.multiplayer.room.exception.RoomForbiddenException; +import com.routecatch.api.multiplayer.room.exception.RoomNotFoundException; import jakarta.servlet.http.HttpServletRequest; @@ -117,6 +120,45 @@ public ResponseEntity handleInvalidCredentials( ); } + @ExceptionHandler(RoomNotFoundException.class) + public ResponseEntity handleRoomNotFound( + RoomNotFoundException exception, + HttpServletRequest request + ) { + return errorResponse( + HttpStatus.NOT_FOUND, + "ROOM_NOT_FOUND", + exception.getMessage(), + request + ); + } + + @ExceptionHandler(RoomClosedException.class) + public ResponseEntity handleRoomClosed( + RoomClosedException exception, + HttpServletRequest request + ) { + return errorResponse( + HttpStatus.CONFLICT, + "ROOM_CLOSED", + exception.getMessage(), + request + ); + } + + @ExceptionHandler(RoomForbiddenException.class) + public ResponseEntity handleRoomForbidden( + RoomForbiddenException exception, + HttpServletRequest request + ) { + return errorResponse( + HttpStatus.FORBIDDEN, + "ROOM_FORBIDDEN", + exception.getMessage(), + request + ); + } + @ExceptionHandler(RoutingEngineException.class) public ResponseEntity handleRoutingEngineException( RoutingEngineException exception, diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/controller/MultiplayerRoomController.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/controller/MultiplayerRoomController.java new file mode 100644 index 0000000..7aef5c3 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/controller/MultiplayerRoomController.java @@ -0,0 +1,95 @@ +package com.routecatch.api.multiplayer.room.controller; + +import java.util.List; + +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.routecatch.api.auth.persistence.UserEntity; +import com.routecatch.api.auth.service.CurrentUserService; +import com.routecatch.api.multiplayer.room.dto.CreateRoomRequest; +import com.routecatch.api.multiplayer.room.dto.RoomResponse; +import com.routecatch.api.multiplayer.room.service.MultiplayerRoomService; + +import jakarta.validation.Valid; + +@RestController +@RequestMapping("/api/multiplayer/rooms") +public class MultiplayerRoomController { + + private final MultiplayerRoomService roomService; + private final CurrentUserService currentUserService; + + public MultiplayerRoomController( + MultiplayerRoomService roomService, + CurrentUserService currentUserService + ) { + this.roomService = roomService; + this.currentUserService = currentUserService; + } + + @PostMapping + public RoomResponse createRoom( + @Valid @RequestBody CreateRoomRequest request, + Authentication authentication + ) { + UserEntity currentUser = currentUserService.getCurrentUserEntity( + authentication + ); + return RoomResponse.from(roomService.createRoom(currentUser, request)); + } + + @GetMapping("/me") + public List listMyRooms(Authentication authentication) { + UserEntity currentUser = currentUserService.getCurrentUserEntity( + authentication + ); + return roomService.listMyRooms(currentUser) + .stream() + .map(RoomResponse::from) + .toList(); + } + + @GetMapping("/{roomCode}") + public RoomResponse getRoom(@PathVariable String roomCode) { + return RoomResponse.from(roomService.getRoom(roomCode)); + } + + @PostMapping("/{roomCode}/join") + public RoomResponse joinRoom( + @PathVariable String roomCode, + Authentication authentication + ) { + UserEntity currentUser = currentUserService.getCurrentUserEntity( + authentication + ); + return RoomResponse.from(roomService.joinRoom(roomCode, currentUser)); + } + + @PostMapping("/{roomCode}/leave") + public RoomResponse leaveRoom( + @PathVariable String roomCode, + Authentication authentication + ) { + UserEntity currentUser = currentUserService.getCurrentUserEntity( + authentication + ); + return RoomResponse.from(roomService.leaveRoom(roomCode, currentUser)); + } + + @PostMapping("/{roomCode}/close") + public RoomResponse closeRoom( + @PathVariable String roomCode, + Authentication authentication + ) { + UserEntity currentUser = currentUserService.getCurrentUserEntity( + authentication + ); + return RoomResponse.from(roomService.closeRoom(roomCode, currentUser)); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/CreateRoomRequest.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/CreateRoomRequest.java new file mode 100644 index 0000000..a7b3ea1 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/CreateRoomRequest.java @@ -0,0 +1,11 @@ +package com.routecatch.api.multiplayer.room.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateRoomRequest( + @NotBlank(message = "must not be blank") + @Size(max = 80, message = "must be at most 80 characters") + String roomName +) { +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomMemberResponse.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomMemberResponse.java new file mode 100644 index 0000000..e2ae03d --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomMemberResponse.java @@ -0,0 +1,24 @@ +package com.routecatch.api.multiplayer.room.dto; + +import java.time.Instant; + +import com.routecatch.api.multiplayer.room.model.RoomMember; + +public record RoomMemberResponse( + String userId, + String username, + String displayName, + Instant joinedAt, + boolean host +) { + + public static RoomMemberResponse from(RoomMember member) { + return new RoomMemberResponse( + member.getUserId().toString(), + member.getUsername(), + member.getDisplayName(), + member.getJoinedAt(), + member.isHost() + ); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomResponse.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomResponse.java new file mode 100644 index 0000000..56d1afb --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/dto/RoomResponse.java @@ -0,0 +1,32 @@ +package com.routecatch.api.multiplayer.room.dto; + +import java.time.Instant; +import java.util.List; + +import com.routecatch.api.multiplayer.room.model.MultiplayerRoom; +import com.routecatch.api.multiplayer.room.model.MultiplayerRoomStatus; + +public record RoomResponse( + String roomCode, + String roomName, + String hostUserId, + String hostDisplayName, + MultiplayerRoomStatus status, + Instant createdAt, + List members +) { + + public static RoomResponse from(MultiplayerRoom room) { + return new RoomResponse( + room.getRoomCode(), + room.getRoomName(), + room.getHostUserId().toString(), + room.getHostDisplayName(), + room.getStatus(), + room.getCreatedAt(), + room.getMembers().stream() + .map(RoomMemberResponse::from) + .toList() + ); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomClosedException.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomClosedException.java new file mode 100644 index 0000000..4d4da45 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomClosedException.java @@ -0,0 +1,8 @@ +package com.routecatch.api.multiplayer.room.exception; + +public class RoomClosedException extends RuntimeException { + + public RoomClosedException(String roomCode) { + super("Multiplayer room is closed: " + roomCode); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomForbiddenException.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomForbiddenException.java new file mode 100644 index 0000000..47d13f3 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomForbiddenException.java @@ -0,0 +1,8 @@ +package com.routecatch.api.multiplayer.room.exception; + +public class RoomForbiddenException extends RuntimeException { + + public RoomForbiddenException(String message) { + super(message); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomNotFoundException.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomNotFoundException.java new file mode 100644 index 0000000..50df836 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/exception/RoomNotFoundException.java @@ -0,0 +1,8 @@ +package com.routecatch.api.multiplayer.room.exception; + +public class RoomNotFoundException extends RuntimeException { + + public RoomNotFoundException(String roomCode) { + super("Multiplayer room not found: " + roomCode); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoom.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoom.java new file mode 100644 index 0000000..6c94e25 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoom.java @@ -0,0 +1,104 @@ +package com.routecatch.api.multiplayer.room.model; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.UUID; + +import com.routecatch.api.auth.persistence.UserEntity; + +public class MultiplayerRoom { + + private final String roomCode; + private final String roomName; + private UUID hostUserId; + private String hostDisplayName; + private MultiplayerRoomStatus status; + private final Instant createdAt; + private final LinkedHashMap members = new LinkedHashMap<>(); + + public MultiplayerRoom(String roomCode, String roomName, UserEntity host) { + this.roomCode = roomCode; + this.roomName = roomName; + this.hostUserId = host.getUserId(); + this.hostDisplayName = host.getDisplayName(); + this.status = MultiplayerRoomStatus.OPEN; + this.createdAt = Instant.now(); + this.members.put(host.getUserId(), new RoomMember(host, true)); + } + + public void addMember(UserEntity user) { + members.computeIfAbsent( + user.getUserId(), + (ignored) -> new RoomMember(user, false) + ); + } + + public void removeMember(UUID userId) { + RoomMember removedMember = members.remove(userId); + + if (removedMember == null || !removedMember.isHost()) { + return; + } + + transferHostOrClose(); + } + + public boolean isHost(UUID userId) { + return hostUserId.equals(userId); + } + + public boolean hasMember(UUID userId) { + return members.containsKey(userId); + } + + public void close() { + status = MultiplayerRoomStatus.CLOSED; + } + + public String getRoomCode() { + return roomCode; + } + + public String getRoomName() { + return roomName; + } + + public UUID getHostUserId() { + return hostUserId; + } + + public String getHostDisplayName() { + return hostDisplayName; + } + + public MultiplayerRoomStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public List getMembers() { + return new ArrayList<>(members.values()); + } + + private void transferHostOrClose() { + members.values().forEach((member) -> member.setHost(false)); + + RoomMember nextHost = members.values().stream() + .findFirst() + .orElse(null); + + if (nextHost == null) { + close(); + return; + } + + nextHost.setHost(true); + hostUserId = nextHost.getUserId(); + hostDisplayName = nextHost.getDisplayName(); + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoomStatus.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoomStatus.java new file mode 100644 index 0000000..7a082f2 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/MultiplayerRoomStatus.java @@ -0,0 +1,7 @@ +package com.routecatch.api.multiplayer.room.model; + +public enum MultiplayerRoomStatus { + OPEN, + IN_PROGRESS, + CLOSED +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/RoomMember.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/RoomMember.java new file mode 100644 index 0000000..0f54796 --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/model/RoomMember.java @@ -0,0 +1,47 @@ +package com.routecatch.api.multiplayer.room.model; + +import java.time.Instant; +import java.util.UUID; + +import com.routecatch.api.auth.persistence.UserEntity; + +public class RoomMember { + + private final UUID userId; + private final String username; + private final String displayName; + private final Instant joinedAt; + private boolean host; + + public RoomMember(UserEntity user, boolean host) { + this.userId = user.getUserId(); + this.username = user.getUsername(); + this.displayName = user.getDisplayName(); + this.joinedAt = Instant.now(); + this.host = host; + } + + public UUID getUserId() { + return userId; + } + + public String getUsername() { + return username; + } + + public String getDisplayName() { + return displayName; + } + + public Instant getJoinedAt() { + return joinedAt; + } + + public boolean isHost() { + return host; + } + + public void setHost(boolean host) { + this.host = host; + } +} diff --git a/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/service/MultiplayerRoomService.java b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/service/MultiplayerRoomService.java new file mode 100644 index 0000000..9993e3b --- /dev/null +++ b/backend/route-catch-api/src/main/java/com/routecatch/api/multiplayer/room/service/MultiplayerRoomService.java @@ -0,0 +1,124 @@ +package com.routecatch.api.multiplayer.room.service; + +import java.security.SecureRandom; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Service; + +import com.routecatch.api.auth.persistence.UserEntity; +import com.routecatch.api.multiplayer.room.dto.CreateRoomRequest; +import com.routecatch.api.multiplayer.room.exception.RoomClosedException; +import com.routecatch.api.multiplayer.room.exception.RoomForbiddenException; +import com.routecatch.api.multiplayer.room.exception.RoomNotFoundException; +import com.routecatch.api.multiplayer.room.model.MultiplayerRoom; +import com.routecatch.api.multiplayer.room.model.MultiplayerRoomStatus; + +@Service +public class MultiplayerRoomService { + + private static final String ROOM_CODE_ALPHABET = + "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private static final int ROOM_CODE_LENGTH = 6; + + private final SecureRandom secureRandom = new SecureRandom(); + private final Map rooms = new ConcurrentHashMap<>(); + + public synchronized MultiplayerRoom createRoom( + UserEntity currentUser, + CreateRoomRequest request + ) { + MultiplayerRoom room = new MultiplayerRoom( + generateRoomCode(), + request.roomName().trim(), + currentUser + ); + rooms.put(room.getRoomCode(), room); + return room; + } + + public synchronized MultiplayerRoom joinRoom( + String roomCode, + UserEntity currentUser + ) { + MultiplayerRoom room = getRoom(roomCode); + + if (room.getStatus() == MultiplayerRoomStatus.CLOSED) { + throw new RoomClosedException(normalizeRoomCode(roomCode)); + } + + room.addMember(currentUser); + return room; + } + + public synchronized MultiplayerRoom leaveRoom( + String roomCode, + UserEntity currentUser + ) { + MultiplayerRoom room = getRoom(roomCode); + room.removeMember(currentUser.getUserId()); + return room; + } + + public MultiplayerRoom getRoom(String roomCode) { + MultiplayerRoom room = rooms.get(normalizeRoomCode(roomCode)); + + if (room == null) { + throw new RoomNotFoundException(normalizeRoomCode(roomCode)); + } + + return room; + } + + public List listMyRooms(UserEntity currentUser) { + return rooms.values() + .stream() + .filter((room) -> room.hasMember(currentUser.getUserId())) + .sorted(Comparator.comparing(MultiplayerRoom::getCreatedAt).reversed()) + .toList(); + } + + public synchronized MultiplayerRoom closeRoom( + String roomCode, + UserEntity currentUser + ) { + MultiplayerRoom room = getRoom(roomCode); + + if (!room.isHost(currentUser.getUserId())) { + throw new RoomForbiddenException( + "Only the room host can close this room" + ); + } + + room.close(); + return room; + } + + private String generateRoomCode() { + String roomCode; + + do { + roomCode = randomRoomCode(); + } while (rooms.containsKey(roomCode)); + + return roomCode; + } + + private String randomRoomCode() { + StringBuilder roomCode = new StringBuilder(ROOM_CODE_LENGTH); + + for (int index = 0; index < ROOM_CODE_LENGTH; index += 1) { + roomCode.append(ROOM_CODE_ALPHABET.charAt( + secureRandom.nextInt(ROOM_CODE_ALPHABET.length()) + )); + } + + return roomCode.toString(); + } + + private String normalizeRoomCode(String roomCode) { + return roomCode.trim().toUpperCase(); + } +} diff --git a/backend/route-catch-api/src/test/java/com/routecatch/api/multiplayer/room/MultiplayerRoomApiTests.java b/backend/route-catch-api/src/test/java/com/routecatch/api/multiplayer/room/MultiplayerRoomApiTests.java new file mode 100644 index 0000000..3d2d695 --- /dev/null +++ b/backend/route-catch-api/src/test/java/com/routecatch/api/multiplayer/room/MultiplayerRoomApiTests.java @@ -0,0 +1,275 @@ +package com.routecatch.api.multiplayer.room; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.jayway.jsonpath.JsonPath; +import com.routecatch.api.auth.persistence.UserRepository; +import com.routecatch.api.game.persistence.CaughtCreatureRepository; +import com.routecatch.api.game.persistence.GameSessionRepository; + +@SpringBootTest +@AutoConfigureMockMvc +class MultiplayerRoomApiTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private CaughtCreatureRepository caughtCreatureRepository; + + @Autowired + private GameSessionRepository gameSessionRepository; + + @Autowired + private UserRepository userRepository; + + @BeforeEach + void clearData() { + caughtCreatureRepository.deleteAll(); + gameSessionRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void createRoomRequiresAuthentication() throws Exception { + mockMvc.perform(post("/api/multiplayer/rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "roomName": "Delhi Room" + } + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value("UNAUTHORIZED")); + } + + @Test + void createRoomSucceeds() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + + mockMvc.perform(post("/api/multiplayer/rooms") + .header("Authorization", "Bearer " + host.token()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "roomName": "Delhi Room" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomCode").isNotEmpty()) + .andExpect(jsonPath("$.roomName").value("Delhi Room")) + .andExpect(jsonPath("$.hostDisplayName").value("Host")) + .andExpect(jsonPath("$.status").value("OPEN")) + .andExpect(jsonPath("$.members", hasSize(1))) + .andExpect(jsonPath("$.members[0].username").value("host")) + .andExpect(jsonPath("$.members[0].host").value(true)); + } + + @Test + void blankRoomNameReturnsValidationError() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + + mockMvc.perform(post("/api/multiplayer/rooms") + .header("Authorization", "Bearer " + host.token()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "roomName": " " + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR")); + } + + @Test + void joinRoomSucceeds() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String roomCode = createRoom(host.token(), "Delhi Room"); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/join", roomCode) + .header("Authorization", "Bearer " + other.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members", hasSize(2))) + .andExpect(jsonPath("$.members[0].username").value("host")) + .andExpect(jsonPath("$.members[1].username").value("other")); + } + + @Test + void joiningSameRoomTwiceIsIdempotent() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String roomCode = createRoom(host.token(), "Delhi Room"); + + joinRoom(other.token(), roomCode); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/join", roomCode) + .header("Authorization", "Bearer " + other.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members", hasSize(2))); + } + + @Test + void leaveRoomSucceeds() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String roomCode = createRoom(host.token(), "Delhi Room"); + joinRoom(other.token(), roomCode); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/leave", roomCode) + .header("Authorization", "Bearer " + other.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members", hasSize(1))) + .andExpect(jsonPath("$.members[0].username").value("host")); + } + + @Test + void hostLeavingTransfersHostToEarliestMember() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String roomCode = createRoom(host.token(), "Delhi Room"); + joinRoom(other.token(), roomCode); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/leave", roomCode) + .header("Authorization", "Bearer " + host.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.members", hasSize(1))) + .andExpect(jsonPath("$.hostDisplayName").value("Other")) + .andExpect(jsonPath("$.members[0].username").value("other")) + .andExpect(jsonPath("$.members[0].host").value(true)); + } + + @Test + void hostCloseSucceeds() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + String roomCode = createRoom(host.token(), "Delhi Room"); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/close", roomCode) + .header("Authorization", "Bearer " + host.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CLOSED")); + } + + @Test + void nonHostCloseReturnsForbidden() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String roomCode = createRoom(host.token(), "Delhi Room"); + joinRoom(other.token(), roomCode); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/close", roomCode) + .header("Authorization", "Bearer " + other.token())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value("ROOM_FORBIDDEN")); + } + + @Test + void joiningClosedRoomReturnsConflict() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String roomCode = createRoom(host.token(), "Delhi Room"); + closeRoom(host.token(), roomCode); + + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/join", roomCode) + .header("Authorization", "Bearer " + other.token())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.errorCode").value("ROOM_CLOSED")); + } + + @Test + void unknownRoomReturnsNotFound() throws Exception { + AuthFixture host = registerUser("host", "host@example.com", "Host"); + + mockMvc.perform(get("/api/multiplayer/rooms/{roomCode}", "000000") + .header("Authorization", "Bearer " + host.token())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value("ROOM_NOT_FOUND")); + } + + @Test + void listMyRoomsReturnsOnlyRoomsWhereUserIsMember() throws Exception { + AuthFixture harsh = registerUser("harsh", "harsh@example.com", "Harsh"); + AuthFixture other = registerUser("other", "other@example.com", "Other"); + String harshRoomCode = createRoom(harsh.token(), "Harsh Room"); + Thread.sleep(2); + String otherRoomCode = createRoom(other.token(), "Other Room"); + joinRoom(harsh.token(), otherRoomCode); + + mockMvc.perform(get("/api/multiplayer/rooms/me") + .header("Authorization", "Bearer " + harsh.token())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].roomCode").value(otherRoomCode)) + .andExpect(jsonPath("$[1].roomCode").value(harshRoomCode)); + } + + private AuthFixture registerUser( + String username, + String email, + String displayName + ) throws Exception { + String response = mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "username": "%s", + "email": "%s", + "displayName": "%s", + "password": "password123" + } + """.formatted(username, email, displayName))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + return new AuthFixture( + JsonPath.read(response, "$.token"), + JsonPath.read(response, "$.user.userId") + ); + } + + private String createRoom(String token, String roomName) throws Exception { + String response = mockMvc.perform(post("/api/multiplayer/rooms") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "roomName": "%s" + } + """.formatted(roomName))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + + return JsonPath.read(response, "$.roomCode"); + } + + private void joinRoom(String token, String roomCode) throws Exception { + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/join", roomCode) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + private void closeRoom(String token, String roomCode) throws Exception { + mockMvc.perform(post("/api/multiplayer/rooms/{roomCode}/close", roomCode) + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } + + private record AuthFixture(String token, String userId) { + } +} From d8b0f3dc61f78d362c489a07b6ee9e9cf7c6a0cd Mon Sep 17 00:00:00 2001 From: halfdimension Date: Sun, 21 Jun 2026 16:49:40 +0530 Subject: [PATCH 2/2] Add frontend multiplayer room lobby --- frontend/src/App.jsx | 12 +- frontend/src/api/multiplayerRoomClient.js | 74 ++++ frontend/src/components/MultiplayerPanel.jsx | 375 +++++++++++++++++-- frontend/src/hooks/useMultiplayerPresence.js | 4 +- frontend/src/styles/global.css | 180 +++++++++ 5 files changed, 606 insertions(+), 39 deletions(-) create mode 100644 frontend/src/api/multiplayerRoomClient.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d7810b8..6602f7e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,7 @@ function App() { currentUser, token, isAuthenticated, + logout, } = useAuth() const { playerPosition, @@ -95,11 +96,9 @@ function App() { ? 'MOVING' : 'IDLE' const { - roomId, connectionStatus: multiplayerConnectionStatus, onlinePlayers, errorMessage: multiplayerErrorMessage, - setRoomId, connectPresence, disconnectPresence, } = useMultiplayerPresence({ @@ -447,13 +446,14 @@ function App() { /> { + if (!isAuthenticated || !token) { + setMyRooms([]) + return + } + + setIsRoomsLoading(true) + try { + const rooms = await listMyRooms(token) + setMyRooms(Array.isArray(rooms) ? rooms : []) + } catch (error) { + if (error.status === 401) { + setRoomError('Session expired. Please sign in again.') + onSessionExpired?.() + } + } finally { + setIsRoomsLoading(false) + } + }, [isAuthenticated, onSessionExpired, token]) + + useEffect(() => { + if (!isAuthenticated) { + const timerId = window.setTimeout(() => { + setActiveRoom(null) + setMyRooms([]) + setRoomMessage('') + setRoomError('') + onDisconnectPresence() + }, 0) + + return () => window.clearTimeout(timerId) + } + + const timerId = window.setTimeout(() => { + void refreshMyRooms() + }, 0) + + return () => window.clearTimeout(timerId) + }, [isAuthenticated, onDisconnectPresence, refreshMyRooms]) + + useEffect(() => { + if (!activeRoom?.roomCode || !token || !isAuthenticated) { + return + } + + let isMounted = true + + getRoom(activeRoom.roomCode, token) + .then((room) => { + if (!isMounted) { + return + } + + if (room?.status === 'CLOSED') { + setActiveRoom(null) + setRoomMessage('Room closed.') + onDisconnectPresence() + void refreshMyRooms() + return + } + + setActiveRoom(room) + }) + .catch((error) => { + if (!isMounted || error.status !== 401) { + return + } + + setRoomError('Session expired. Please sign in again.') + onSessionExpired?.() + }) + + return () => { + isMounted = false + } + }, [ + activeRoom?.roomCode, + isAuthenticated, + onDisconnectPresence, + onSessionExpired, + onlinePlayerCount, + refreshMyRooms, + token, + ]) + + function handleRoomError(error, fallbackMessage) { + if (error.status === 401) { + setRoomError('Session expired. Please sign in again.') + onSessionExpired?.() + return + } + + setRoomError(error.message || fallbackMessage) + } + + async function activateRoom(room, message) { + setActiveRoom(room) + setJoinCode(room.roomCode) + setRoomMessage(message) + setRoomError('') + onConnectPresence(room.roomCode) + await refreshMyRooms() + } + + async function handleCreateRoom() { + const nextRoomName = roomName.trim() || DEFAULT_ROOM_NAME + setIsActionPending(true) + setRoomMessage('') + setRoomError('') + + try { + const room = await createRoom({ roomName: nextRoomName }, token) + await activateRoom(room, 'Room created.') + } catch (error) { + handleRoomError(error, 'Could not create room.') + } finally { + setIsActionPending(false) + } + } + + async function handleJoinRoom(roomCode = joinCode) { + const normalizedRoomCode = normalizeRoomCode(roomCode) + + if (!normalizedRoomCode) { + setRoomError('Enter a room code.') + return + } + + setIsActionPending(true) + setRoomMessage('') + setRoomError('') + + try { + const room = await joinRoom(normalizedRoomCode, token) + await activateRoom(room, 'Joined room.') + } catch (error) { + handleRoomError(error, 'Could not join room.') + } finally { + setIsActionPending(false) + } + } + + async function handleLeaveRoom() { + if (!activeRoom?.roomCode) { + return + } + + setIsActionPending(true) + setRoomMessage('') + setRoomError('') + + try { + await leaveRoom(activeRoom.roomCode, token) + setActiveRoom(null) + setRoomMessage('Left room.') + onDisconnectPresence() + await refreshMyRooms() + } catch (error) { + handleRoomError(error, 'Could not leave room.') + } finally { + setIsActionPending(false) + } + } + + async function handleCloseRoom() { + if (!activeRoom?.roomCode) { + return + } + + setIsActionPending(true) + setRoomMessage('') + setRoomError('') + + try { + await closeRoom(activeRoom.roomCode, token) + setActiveRoom(null) + setRoomMessage('Room closed.') + onDisconnectPresence() + await refreshMyRooms() + } catch (error) { + handleRoomError(error, 'Could not close room.') + } finally { + setIsActionPending(false) + } + } + + const isHost = Boolean( + activeRoom?.hostUserId && activeRoom.hostUserId === currentUser?.userId, + ) + const memberCount = activeRoom?.members?.length || 0 return ( -
+

Multiplayer

@@ -21,45 +239,140 @@ function MultiplayerPanel({
{!isAuthenticated ? ( -

Sign in to use multiplayer

- ) : ( - <> - +

Sign in to use multiplayer rooms.

+ ) : activeRoom ? ( +
+
+
+ {activeRoom.roomName} + {activeRoom.roomCode} +
+ + {activeRoom.status || 'UNKNOWN'} + +
+ +
+ + Members {memberCount} + + + Online {onlinePlayerCount} + +
+ +
    + {(activeRoom.members || []).map((member) => ( +
  • + {member.displayName || member.username} + {member.host && Host} +
  • + ))} +
+ {isHost && ( + + )} +
+
+ ) : ( + <> +
+

Create Room

+
-

- Online: {onlinePlayerCount} -

+
+

Join Room

+
+ + +
+
+ +
+
+

My Rooms

+ +
+ {myRooms.length > 0 ? ( +
    + {myRooms.slice(0, 3).map((room) => ( +
  • + +
  • + ))} +
+ ) : ( +

No rooms yet.

+ )} +
)} - {errorMessage && ( -

{errorMessage}

+ {(roomMessage || roomError || errorMessage) && ( +

+ {roomError || errorMessage || roomMessage} +

)}
) diff --git a/frontend/src/hooks/useMultiplayerPresence.js b/frontend/src/hooks/useMultiplayerPresence.js index 893e1d8..531b04e 100644 --- a/frontend/src/hooks/useMultiplayerPresence.js +++ b/frontend/src/hooks/useMultiplayerPresence.js @@ -96,13 +96,13 @@ export function useMultiplayerPresence({ } }, []) - const connectPresence = useCallback(() => { + const connectPresence = useCallback((requestedRoomId) => { if (!token || !currentUser) { setErrorMessage('Sign in to use multiplayer') return } - const nextRoomId = roomId.trim() || DEFAULT_ROOM_ID + const nextRoomId = (requestedRoomId || roomId).trim() || DEFAULT_ROOM_ID disconnectPresence() manualDisconnectRef.current = false setRoomId(nextRoomId) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 7edf512..ebbd431 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -377,6 +377,11 @@ button:focus-visible { text-transform: capitalize; } +.multiplayer-status.is-disconnected { + color: #64748b; + background: #f1f5f9; +} + .multiplayer-status.is-connected { color: #166534; background: #dcfce7; @@ -392,6 +397,33 @@ button:focus-visible { background: #fee2e2; } +.multiplayer-section { + display: grid; + gap: 5px; + padding-top: 6px; + border-top: 1px solid #e2e8f0; +} + +.multiplayer-section > p, +.multiplayer-section-heading p { + margin: 0; + color: #334155; + font-size: 11px; + font-weight: 800; +} + +.multiplayer-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.multiplayer-section-heading button { + padding: 3px 6px; + font-size: 10px; +} + .multiplayer-room-control { display: grid; gap: 3px; @@ -414,6 +446,154 @@ button:focus-visible { font-size: 13px; } +.multiplayer-join-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 52px; + align-items: end; + gap: 6px; +} + +.multiplayer-join-row button, +.multiplayer-section > button { + width: 100%; + padding: 6px 8px; +} + +.multiplayer-active-room { + display: grid; + gap: 7px; +} + +.multiplayer-room-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.multiplayer-room-summary div { + display: grid; + min-width: 0; + gap: 2px; +} + +.multiplayer-room-summary strong { + overflow: hidden; + color: #172033; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.multiplayer-room-summary span:not(.multiplayer-room-status) { + width: fit-content; + padding: 2px 6px; + border-radius: 5px; + color: #334155; + background: #eef2f7; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; +} + +.multiplayer-room-status { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 999px; + color: #475569; + background: #f1f5f9; + font-size: 9px; + font-weight: 800; +} + +.multiplayer-room-status.is-open { + color: #166534; + background: #dcfce7; +} + +.multiplayer-room-status.is-in-progress { + color: #1d4ed8; + background: #dbeafe; +} + +.multiplayer-room-status.is-closed { + color: #64748b; + background: #e2e8f0; +} + +.multiplayer-room-meta { + display: flex; + gap: 8px; + color: #64748b; + font-size: 11px; +} + +.multiplayer-room-meta strong { + color: #172033; +} + +.multiplayer-member-list, +.multiplayer-my-rooms { + display: grid; + gap: 4px; + max-height: 108px; + margin: 0; + overflow: auto; + padding: 0; + list-style: none; +} + +.multiplayer-member-list li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 26px; + padding: 5px 6px; + border-radius: 6px; + background: #f3f6f8; + color: #334155; + font-size: 11px; +} + +.multiplayer-member-list li span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.multiplayer-member-list li strong { + flex: 0 0 auto; + padding: 1px 5px; + border-radius: 999px; + color: #1d4ed8; + background: #dbeafe; + font-size: 9px; +} + +.multiplayer-my-rooms button { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 6px; + font-size: 11px; +} + +.multiplayer-my-rooms span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.multiplayer-my-rooms strong { + color: #334155; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 10px; +} + .multiplayer-actions { display: grid; grid-template-columns: 1fr 1fr;