Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -117,6 +120,45 @@ public ResponseEntity<ApiErrorResponse> handleInvalidCredentials(
);
}

@ExceptionHandler(RoomNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleRoomNotFound(
RoomNotFoundException exception,
HttpServletRequest request
) {
return errorResponse(
HttpStatus.NOT_FOUND,
"ROOM_NOT_FOUND",
exception.getMessage(),
request
);
}

@ExceptionHandler(RoomClosedException.class)
public ResponseEntity<ApiErrorResponse> handleRoomClosed(
RoomClosedException exception,
HttpServletRequest request
) {
return errorResponse(
HttpStatus.CONFLICT,
"ROOM_CLOSED",
exception.getMessage(),
request
);
}

@ExceptionHandler(RoomForbiddenException.class)
public ResponseEntity<ApiErrorResponse> handleRoomForbidden(
RoomForbiddenException exception,
HttpServletRequest request
) {
return errorResponse(
HttpStatus.FORBIDDEN,
"ROOM_FORBIDDEN",
exception.getMessage(),
request
);
}

@ExceptionHandler(RoutingEngineException.class)
public ResponseEntity<ApiErrorResponse> handleRoutingEngineException(
RoutingEngineException exception,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RoomResponse> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<RoomMemberResponse> 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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.routecatch.api.multiplayer.room.exception;

public class RoomForbiddenException extends RuntimeException {

public RoomForbiddenException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<UUID, RoomMember> 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<RoomMember> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.routecatch.api.multiplayer.room.model;

public enum MultiplayerRoomStatus {
OPEN,
IN_PROGRESS,
CLOSED
}
Loading