diff --git a/.gitignore b/.gitignore index a71cd2b8..f8a64ed3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ build/ ### VS Code ### .vscode/ + +### State Persistence ### +data/ diff --git a/docker-compose.yml b/docker-compose.yml index 62960875..44d70a2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,4 +3,6 @@ services: image: ghcr.io/ss26-se2-codenames/backend:latest restart: unless-stopped ports: - - "53213:8080" \ No newline at end of file + - "53213:8080" + volumes: + - ./data:/app/data \ No newline at end of file diff --git a/src/main/java/com/codenames/codenames/backend/chat/ChatController.java b/src/main/java/com/codenames/codenames/backend/chat/ChatController.java index 23a7c814..91c22ddc 100644 --- a/src/main/java/com/codenames/codenames/backend/chat/ChatController.java +++ b/src/main/java/com/codenames/codenames/backend/chat/ChatController.java @@ -1,27 +1,25 @@ package com.codenames.codenames.backend.chat; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.utility.ChatMessageType; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; -import com.codenames.codenames.backend.websocket.SessionRegistry; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.stereotype.Controller; - /** * Controller for broadcasting client messages to the desired destination with STOMP. * *

The destination is based on the lobbyID or team parameters passed when the method is invoked. * The parameters are appended to the destination and broadcasted to all subscribers. */ + @Controller public class ChatController { private final ChatService chatService; private final LobbyService lobbyService; - private final SessionRegistry sessionRegistry; /** * Constructor for the ChatController. @@ -29,39 +27,9 @@ public class ChatController { * @param chatService the service used to validate and persist chat history */ public ChatController( - ChatService chatService, LobbyService lobbyService, SessionRegistry sessionRegistry) { + ChatService chatService, LobbyService lobbyService) { this.chatService = chatService; this.lobbyService = lobbyService; - this.sessionRegistry = sessionRegistry; - } - - /** - * Verifies the username of the sender and checks if they are sending the message to their own - * lobby before allowing them to send a message. - * - * @param headerAccessor the header accessor containing the session information of the sender - * @param targetLobbyId the actual lobby ID associated with the sender's session, used to verify - * that they are sending the message to their own lobby - * @return the username if sender is verified, otherwise throws an exception - * @throws IllegalStateException if the username is null or if the sender is trying to send a - * message to a lobby they are not in - */ - private String getVerifiedUsername( - SimpMessageHeaderAccessor headerAccessor, String targetLobbyId) { - String sessionId = headerAccessor.getSessionId(); - String realUsername = sessionRegistry.getUser(sessionId); - String realLobbyId = sessionRegistry.getLobby(sessionId); - - if (realUsername == null) { - throw new IllegalStateException("Null username."); - } - if (targetLobbyId == null) { - throw new IllegalStateException("Null lobby ID."); - } - if (!targetLobbyId.equals(realLobbyId)) { - throw new IllegalStateException("Wrong lobby, user not in lobby " + realLobbyId); - } - return realUsername; } /** @@ -72,18 +40,19 @@ private String getVerifiedUsername( */ @MessageMapping("/chat/{lobbyId}") public void sendLobbyMessage( - @DestinationVariable String lobbyId, - @Payload ChatDto chatDto, - SimpMessageHeaderAccessor headerAccessor) { - String realUsername = getVerifiedUsername(headerAccessor, lobbyId); - - // Create a new ChatDto with the verified username to prevent passing false username - ChatDto verifiedChatDto = new ChatDto(realUsername, chatDto.content(), chatDto.type()); + @DestinationVariable String lobbyId, + @Payload ChatDto chatDto) { + + ChatDto verifiedChatDto = new ChatDto( + chatDto.senderUsername(), + chatDto.content(), + ChatMessageType.CHAT + ); chatService.processMessage(lobbyId, "LOBBY", "", verifiedChatDto); } /** - * Verifies the sender's name, lobbyID, and team before sending a message to the entire team. + * Verifies the sender's team and delegates message processing to {@link ChatService}. * * @param lobbyId the ID of the lobby the client is in * @param team the team the client is in (RED, BLUE) @@ -93,16 +62,18 @@ public void sendLobbyMessage( public void sendTeamMessage( @DestinationVariable String lobbyId, @DestinationVariable Team team, - @Payload ChatDto chatDto, - SimpMessageHeaderAccessor headerAccessor) { - String realUsername = getVerifiedUsername(headerAccessor, lobbyId); + @Payload ChatDto chatDto) { - Team playerTeam = lobbyService.getPlayerTeam(realUsername, lobbyId); + Team playerTeam = lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId); if (playerTeam != team) { throw new IllegalStateException("You are not on team " + team.name()); } - ChatDto verifiedDto = new ChatDto(realUsername, chatDto.content(), chatDto.type()); + ChatDto verifiedDto = new ChatDto( + chatDto.senderUsername(), + chatDto.content(), + ChatMessageType.CHAT + ); String roomKey = "TEAM_" + team.name(); String topicSuffix = "/" + team.name(); @@ -110,8 +81,7 @@ public void sendTeamMessage( } /** - * Verifies the sender's name, lobbyID, team and role sending a message to the operatives on the - * respective team. + * Verifies the sender's team and role and delegates message processing to {@link ChatService}. * * @param lobbyId the ID of the lobby the client is in * @param team the team the client is in (RED, BLUE) @@ -121,19 +91,21 @@ public void sendTeamMessage( public void sendTeamOperativeMessage( @DestinationVariable String lobbyId, @DestinationVariable Team team, - @Payload ChatDto chatDto, - SimpMessageHeaderAccessor headerAccessor) { - String realUsername = getVerifiedUsername(headerAccessor, lobbyId); + @Payload ChatDto chatDto) { - Team playerTeam = lobbyService.getPlayerTeam(realUsername, lobbyId); - Role playerRole = lobbyService.getPlayerRole(realUsername, lobbyId); + Team playerTeam = lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId); + Role playerRole = lobbyService.getPlayerRole(chatDto.senderUsername(), lobbyId); if (playerTeam != team || playerRole != Role.OPERATIVE) { throw new IllegalStateException( "You are either not an operative or are sending to the wrong team."); } - ChatDto verifiedDto = new ChatDto(realUsername, chatDto.content(), chatDto.type()); + ChatDto verifiedDto = new ChatDto( + chatDto.senderUsername(), + chatDto.content(), + ChatMessageType.CHAT + ); String roomKey = "OPERATIVE_" + team.name(); String topicSuffix = "/" + team.name() + "/operative"; diff --git a/src/main/java/com/codenames/codenames/backend/controller/HealthController.java b/src/main/java/com/codenames/codenames/backend/controller/HealthController.java new file mode 100644 index 00000000..b19a3425 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/controller/HealthController.java @@ -0,0 +1,23 @@ +package com.codenames.codenames.backend.controller; + +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller providing a simple health check endpoint to verify that the backend application is + * running. + */ +@RestController +public class HealthController { + + /** + * Returns the current health status of the backend. + * + * @return a map containing the application status + */ + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP"); + } +} diff --git a/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java b/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java new file mode 100644 index 00000000..59d41e9b --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java @@ -0,0 +1,114 @@ +package com.codenames.codenames.backend.game.controller; + +import com.codenames.codenames.backend.clue.Clue; +import com.codenames.codenames.backend.game.dto.ClueMessage; +import com.codenames.codenames.backend.game.dto.PassTurnMessage; +import com.codenames.codenames.backend.game.dto.RevealCardMessage; +import com.codenames.codenames.backend.game.dto.StartGameMessage; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Controller; + +/** + * WebSocket controller for real-time gameplay interactions. + * + *

Handles game-related WebSocket requests such as starting games, submitting clues, and + * revealing cards. Broadcasts updated game state information to subscribed clients. + */ +@Controller +public class GameSocketController { + + private final GameService gameService; + + private final SimpMessagingTemplate messagingTemplate; + private final SystemStatePersistenceService persistenceService; + + private static final String GAME_TOPIC_PREFIX = "/topic/game/"; + + /** + * Creates a new {@code GameSocketController}. + * + * @param gameService service responsible for gameplay logic + * @param messagingTemplate template used for broadcasting websocket messages + * @param persistenceService service used to persist current backend state + */ + public GameSocketController( + GameService gameService, + SimpMessagingTemplate messagingTemplate, + SystemStatePersistenceService persistenceService) { + + this.gameService = gameService; + this.messagingTemplate = messagingTemplate; + this.persistenceService = persistenceService; + } + + /** + * Sends the current game state to subscribed players. + * + * @param message contains the lobby code + */ + @MessageMapping("/start-game") + public void startGame(StartGameMessage message) { + + messagingTemplate.convertAndSend( + GAME_TOPIC_PREFIX + message.getLobbyCode(), + gameService.getCurrentGameState(message.getLobbyCode())); + } + + /** + * Reveals a card on the board. + * + *

Updates the game state and broadcasts the updated board to all subscribed players. + * + * @param message the reveal card request + */ + @MessageMapping("/reveal-card") + public void revealCard(RevealCardMessage message) { + + gameService.flipCard(message.getLobbyCode(), message.getPosition(), message.getCurrentTurn()); + persistenceService.persistCurrentState(); + + messagingTemplate.convertAndSend( + GAME_TOPIC_PREFIX + message.getLobbyCode(), + gameService.getCurrentGameState(message.getLobbyCode())); + } + + /** + * Submits a clue for the current turn. + * + *

Updates the current clue and broadcasts the updated game state to all subscribed players. + * + * @param message the clue submission request + */ + @MessageMapping("/submit-clue") + public void submitClue(ClueMessage message) { + + gameService.submitClue( + message.getLobbyCode(), + new Clue(message.getWord(), message.getGuessAmount()), + message.getCurrentTurn()); + persistenceService.persistCurrentState(); + + messagingTemplate.convertAndSend( + GAME_TOPIC_PREFIX + message.getLobbyCode(), + gameService.getCurrentGameState(message.getLobbyCode())); + } + + /** + * Ends the current turn early and broadcasts the updated game state. + * + * @param message contains lobby and team information + */ + @MessageMapping("/pass-turn") + public void passTurn(PassTurnMessage message) { + + gameService.passTurn(message.getLobbyCode(), message.getCurrentTurn()); + persistenceService.persistCurrentState(); + + messagingTemplate.convertAndSend( + GAME_TOPIC_PREFIX + message.getLobbyCode(), + gameService.getCurrentGameState(message.getLobbyCode())); + } +} diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/ClueDto.java b/src/main/java/com/codenames/codenames/backend/game/dto/ClueDto.java new file mode 100644 index 00000000..26a54187 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/game/dto/ClueDto.java @@ -0,0 +1,12 @@ +package com.codenames.codenames.backend.game.dto; + +/** + * Data transfer object for a clue, containing the clue word and the allowed number of guesses. + * This is only used for sending clues, not for receiving them. + * Therefore, the +1 is already included in the guessAmount. + * + * @param word the hint word + * @param guessAmount how many guesses are allowed + */ +public record ClueDto(String word, int guessAmount) { +} diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/ClueMessage.java b/src/main/java/com/codenames/codenames/backend/game/dto/ClueMessage.java new file mode 100644 index 00000000..6b120c95 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/game/dto/ClueMessage.java @@ -0,0 +1,19 @@ +package com.codenames.codenames.backend.game.dto; + +import com.codenames.codenames.backend.utility.Team; +import lombok.Getter; +import lombok.Setter; + +/** + * WebSocket message for submitting a clue. + * + *

Contains the clue word and the allowed amount of guesses. + */ +@Getter +@Setter +public class ClueMessage { + private String lobbyCode; + private String word; + private int guessAmount; + private Team currentTurn; +} diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/PassTurnMessage.java b/src/main/java/com/codenames/codenames/backend/game/dto/PassTurnMessage.java new file mode 100644 index 00000000..10812d83 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/game/dto/PassTurnMessage.java @@ -0,0 +1,17 @@ +package com.codenames.codenames.backend.game.dto; + +import com.codenames.codenames.backend.utility.Team; +import lombok.Getter; +import lombok.Setter; + +/** + * Message used for requesting an early turn pass. + * + *

Contains the lobby code and team initiating the action. + */ +@Getter +@Setter +public class PassTurnMessage { + private String lobbyCode; + private Team currentTurn; +} diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/RevealCardMessage.java b/src/main/java/com/codenames/codenames/backend/game/dto/RevealCardMessage.java new file mode 100644 index 00000000..940d4676 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/game/dto/RevealCardMessage.java @@ -0,0 +1,18 @@ +package com.codenames.codenames.backend.game.dto; + +import com.codenames.codenames.backend.utility.Team; +import lombok.Getter; +import lombok.Setter; + +/** + * WebSocket message for revealing a card on the board. + * + *

Contains the lobby code, selected card position, and the current team's turn color. + */ +@Getter +@Setter +public class RevealCardMessage { + private String lobbyCode; + private int position; + private Team currentTurn; +} diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/StartGameMessage.java b/src/main/java/com/codenames/codenames/backend/game/dto/StartGameMessage.java new file mode 100644 index 00000000..e8d846aa --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/game/dto/StartGameMessage.java @@ -0,0 +1,15 @@ +package com.codenames.codenames.backend.game.dto; + +import lombok.Getter; +import lombok.Setter; + +/** + * WebSocket message for starting a game session. + * + *

Contains the lobby code of the game that should be started. + */ +@Getter +@Setter +public class StartGameMessage { + private String lobbyCode; +} diff --git a/src/main/java/com/codenames/codenames/backend/lobby/Lobby.java b/src/main/java/com/codenames/codenames/backend/lobby/Lobby.java index 21d885b4..05303511 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/Lobby.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/Lobby.java @@ -11,9 +11,10 @@ import lombok.Getter; /** - * Represents a game lobby containing a limited number of players. + * Represents a game lobby containing a limited number of playerList. * - *

Supports adding and removing players while enforcing constraints such as maximum player count + *

Supports adding and removing playerList + * while enforcing constraints such as maximum player count * and unique usernames. */ @Getter @@ -44,33 +45,45 @@ public Lobby(String lobbyCode, String username) { this.lobbyCode = lobbyCode; this.playerTeams = new HashMap<>(); this.playerRoles = new HashMap<>(); - this.addPlayer(username); + this.addPlayer(username, true); } /** * Adds a player to the lobby if capacity allows and the username is unique. * * @param username the username of the player + * @param isHost whether the player is the host of the lobby * @return {@code true} if the player was added, {@code false} otherwise */ - public boolean addPlayer(String username) { - boolean alreadyExists = playerList.stream().anyMatch(p -> p.getUsername().equals(username)); + public boolean addPlayer(String username, boolean isHost) { + boolean alreadyExists = playerList.stream().anyMatch(p -> p.username().equals(username)); if (alreadyExists || playerList.size() >= MAX_PLAYERS) { return false; } - playerList.add(new Player(username)); + playerList.add(new Player(username, isHost)); return true; } + /** + * Adds a player to the lobby if capacity allows and the username is unique. + * Calls {@link #addPlayer(String, boolean)} with {@code false} as the second argument + * + * @param username the username of the player + * @return {@code true} if the player was added, {@code false} otherwise + */ + public boolean addPlayer(String username) { + return addPlayer(username, false); + } + /** * Removes a player from the lobby. * * @param username the username of the player to remove */ public void removePlayer(String username) { - playerList.removeIf(p -> p.getUsername().equals(username)); + playerList.removeIf(p -> p.username().equals(username)); this.playerTeams.remove(username); this.playerRoles.remove(username); } @@ -82,14 +95,14 @@ public void removePlayer(String username) { * @return {@code true} if the player exists in the lobby, {@code false} otherwise */ public boolean hasPlayer(String username) { - return playerList.stream().anyMatch(p -> p.getUsername().equals(username)); + return playerList.stream().anyMatch(p -> p.username().equals(username)); } /** * Sets the team for a player. * * @param username the username of the player - * @param team the team to assign + * @param team the team to assign */ public void setPlayerTeam(String username, Team team) { playerTeams.put(username, team); @@ -99,7 +112,7 @@ public void setPlayerTeam(String username, Team team) { * Sets the role for a player. * * @param username the username of the player - * @param role the role to assign + * @param role the role to assign */ public void setPlayerRole(String username, Role role) { playerRoles.put(username, role); diff --git a/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java b/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java index 201c3ffd..4415ca4e 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java @@ -1,9 +1,13 @@ package com.codenames.codenames.backend.lobby.controller; import com.codenames.codenames.backend.lobby.dto.LobbyResponse; -import com.codenames.codenames.backend.lobby.dto.PositionSelectMessage; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; +import java.util.List; import org.springframework.http.ResponseEntity; +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; @@ -16,20 +20,23 @@ *

Provides endpoints for creating, joining, and leaving lobbies. Delegates business logic to * {@link LobbyService}. */ - @RestController @RequestMapping("/lobby") public class LobbyController { private final LobbyService service; + private final SystemStatePersistenceService persistenceService; + private static final String LOBBY_NOT_FOUND = "Could not find lobby."; /** * Creates a new {@code LobbyController}. * * @param service the lobby service used to handle business logic + * @param persistenceService service used to persist current backend state */ - public LobbyController(LobbyService service) { + public LobbyController(LobbyService service, SystemStatePersistenceService persistenceService) { this.service = service; + this.persistenceService = persistenceService; } /** @@ -38,81 +45,139 @@ public LobbyController(LobbyService service) { * @param username the username of the requesting user * @return a response containing the result and the generated lobby code */ - - @PostMapping("/create") + @GetMapping("/create") public ResponseEntity createLobby(@RequestParam String username) { String lobbyCode = service.createLobby(username); if (lobbyCode == null || lobbyCode.isBlank()) { return ResponseEntity.internalServerError() - .body(new LobbyResponse("Error while creating lobby.", "")); + .body(new LobbyResponse("Error while creating lobby.", "", null, false)); } else { - return ResponseEntity.ok(new LobbyResponse("Successfully created Lobby.", lobbyCode)); + + List players = service.getPlayersDto(lobbyCode); + return ResponseEntity.ok( + new LobbyResponse("Successfully created Lobby.", lobbyCode, players, false)); } } /** * Handles a request to join an existing lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby * @return a response indicating whether the join was successful */ - @PostMapping("/join") + @GetMapping("/{lobbyCode}/join") public ResponseEntity joinLobby( - @RequestParam String username, @RequestParam String lobbyCode) { + @RequestParam String username, @PathVariable String lobbyCode) { boolean joined = service.joinLobby(username, lobbyCode); if (joined) { - return ResponseEntity.ok(new LobbyResponse("Joined Lobby successfully.", lobbyCode)); + + return ResponseEntity.ok( + new LobbyResponse( + "Joined Lobby successfully.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } else { return ResponseEntity.badRequest() - .body(new LobbyResponse("Could not find lobby.", lobbyCode)); + .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); } } /** * Handles a request to leave a lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby * @return a response indicating whether the operation was successful */ - @PostMapping("/leave") + @GetMapping("/{lobbyCode}/leave") public ResponseEntity leaveLobby( - @RequestParam String username, @RequestParam String lobbyCode) { + @PathVariable String lobbyCode, @RequestParam String username) { + boolean wasStarted = service.getIsStarted(lobbyCode); boolean left = service.leaveLobby(username, lobbyCode); + if (left) { - return ResponseEntity.ok(new LobbyResponse("Left lobby successfully.", lobbyCode)); + service.checkLobbyStillHasPlayers(lobbyCode); + + if (wasStarted) { + persistenceService.persistCurrentState(); + } + + return ResponseEntity.ok( + new LobbyResponse( + "Left lobby successfully.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } else { return ResponseEntity.badRequest() - .body(new LobbyResponse("Could not find lobby.", lobbyCode)); + .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); } } + /** + * An endpoint for retrieving all lobby-specific info used during polling in lobby-state. + * + * @param lobbyCode unique lobby code + * @return a response entity with the http code 200 for ok and 400 for bad request, if an error + * occurred + */ + @GetMapping("/{lobbyCode}") + public ResponseEntity getLobbyInfo(@PathVariable String lobbyCode) { + List players = service.getPlayersDto(lobbyCode); + boolean isStarted = service.getIsStarted(lobbyCode); + return ResponseEntity.ok( + new LobbyResponse("Lobby info retrieved successfully.", lobbyCode, players, isStarted)); + } + /** * Handles a request to select a team and role for a player. * * @param request the position selection request containing username, lobby code, team, and role * @return a response indicating whether the selection was successful */ - @PostMapping("/select-position") + @PostMapping("/{lobbyCode}/select-position") public ResponseEntity selectPosition( - @RequestBody PositionSelectMessage request - ) { - boolean updated = service.selectPosition( - request.getUsername(), - request.getLobbyCode(), - request.getTeam(), - request.getRole() - ); + @PathVariable String lobbyCode, @RequestBody PlayerDto request) { + boolean updated = + service.selectPosition(request.username(), lobbyCode, request.team(), request.role()); if (updated) { + return ResponseEntity.ok( - new LobbyResponse("Position selected successfully.", request.getLobbyCode()) - ); + new LobbyResponse( + "Position selected successfully.", + lobbyCode, + service.getPlayersDto(lobbyCode), + false)); } else { - return ResponseEntity.badRequest().body( - new LobbyResponse("Could not assign selected team/role.", request.getLobbyCode()) - ); + return ResponseEntity.badRequest() + .body( + new LobbyResponse( + "Could not assign selected team/role.", + lobbyCode, + service.getPlayersDto(lobbyCode), + false)); + } + } + + /** + * Endpoint for starting a game, this is the last http-request only the host can make. + * + * @param lobbyCode the unique lobby code + * @param username the name of the requesting user + * @return a response entity of a lobby response, with isStarted @code true or @code false, + * whether the starting was successful or not + */ + @GetMapping("/{lobbyCode}/start-game") + public ResponseEntity startGame( + @PathVariable String lobbyCode, @RequestParam String username) { + boolean isStarted = service.startGame(lobbyCode, username); + + if (isStarted) { + persistenceService.persistCurrentState(); + return ResponseEntity.ok( + new LobbyResponse( + "Game is starting now.", lobbyCode, service.getPlayersDto(lobbyCode), true)); } + return ResponseEntity.badRequest() + .body( + new LobbyResponse( + "Could not start the game.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } } diff --git a/src/main/java/com/codenames/codenames/backend/lobby/dto/LobbyResponse.java b/src/main/java/com/codenames/codenames/backend/lobby/dto/LobbyResponse.java index 1f8feb30..1b750c27 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/dto/LobbyResponse.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/dto/LobbyResponse.java @@ -1,25 +1,15 @@ package com.codenames.codenames.backend.lobby.dto; -import lombok.Getter; +import java.util.List; /** * Data transfer object representing the result of a lobby operation. * *

Contains a message describing the outcome and the associated lobby code. */ -@Getter -public class LobbyResponse { - private final String message; - private final String lobbyCode; - - /** - * Creates a new lobby response. - * - * @param message the message describing the result of the operation - * @param lobbyCode the associated lobby code - */ - public LobbyResponse(String message, String lobbyCode) { - this.lobbyCode = lobbyCode; - this.message = message; - } -} +public record LobbyResponse( + String message, + String lobbyCode, + List playerList, + boolean isStarted +) {} diff --git a/src/main/java/com/codenames/codenames/backend/lobby/dto/PlayerDto.java b/src/main/java/com/codenames/codenames/backend/lobby/dto/PlayerDto.java new file mode 100644 index 00000000..94edb594 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/lobby/dto/PlayerDto.java @@ -0,0 +1,15 @@ +package com.codenames.codenames.backend.lobby.dto; + +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; + +/** + * A data transfer object for communicating with the frontend, holds user-specific information. + * + * @param username the name of the user + * @param team the user's current team, this can be null + * @param role the user's current role, this can be null + * @param isHost whether the user is host + */ +public record PlayerDto(String username, Team team, Role role, boolean isHost) { +} diff --git a/src/main/java/com/codenames/codenames/backend/lobby/dto/PositionSelectMessage.java b/src/main/java/com/codenames/codenames/backend/lobby/dto/PositionSelectMessage.java deleted file mode 100644 index 8cb795eb..00000000 --- a/src/main/java/com/codenames/codenames/backend/lobby/dto/PositionSelectMessage.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.codenames.codenames.backend.lobby.dto; - -import com.codenames.codenames.backend.utility.Role; -import com.codenames.codenames.backend.utility.Team; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** - * DTO for selecting a position (team and role) in a lobby. - */ -@Getter -@Setter -@NoArgsConstructor -public class PositionSelectMessage { - - private String username; - private String lobbyCode; - private Team team; - private Role role; -} \ No newline at end of file diff --git a/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyCodeGenerator.java b/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyCodeGenerator.java index 8953e58e..c878bcf2 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyCodeGenerator.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyCodeGenerator.java @@ -12,7 +12,6 @@ @Service public class LobbyCodeGenerator { - @SuppressWarnings("SpellCheckingInspection") private static final String CHARACTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private static final int CODE_LENGTH = 5; diff --git a/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java b/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java index 0af52802..91b542db 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java @@ -1,33 +1,46 @@ package com.codenames.codenames.backend.lobby.services; +import com.codenames.codenames.backend.chat.ChatService; import com.codenames.codenames.backend.lobby.Lobby; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.playingfield.GameService; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import com.codenames.codenames.backend.websocket.Player; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; /** * Service responsible for managing lobbies and player interactions. * - *

Handles creation of lobbies, player joins/leaves, and retrieval of lobby data. - * Ensures uniqueness of lobby codes and thread-safe access to lobby storage. + *

Handles creation of lobbies, player joins/leaves, and retrieval of lobby data. Ensures + * uniqueness of lobby codes and thread-safe access to lobby storage. */ +@Slf4j @Service public class LobbyService { + @Getter private final Map lobbyList = new ConcurrentHashMap<>(); private final LobbyCodeGenerator generator; + private final GameService gameService; + private final ChatService chatService; /** * Creates a new {@code LobbyService}. * * @param generator the lobby code generator used to create unique lobby codes */ - public LobbyService(LobbyCodeGenerator generator) { + public LobbyService( + LobbyCodeGenerator generator, ChatService chatService, GameService gameService) { this.generator = generator; + this.chatService = chatService; + this.gameService = gameService; } /** @@ -39,33 +52,58 @@ public LobbyService(LobbyCodeGenerator generator) { public String createLobby(String username) { String lobbyCode = generateLobbyCode(); if (lobbyCode == null || lobbyCode.isBlank()) { + log.error("ERROR: there was an error when generating a lobby code"); return null; } Lobby lobby = new Lobby(lobbyCode, username); lobbyList.put(lobbyCode, lobby); + log.info("{}: a lobby has been created", lobbyCode); return lobbyCode; } + /** + * Helper method to add the GameManager once a lobby is created. + * + * @param lobby the lobby object to determine the starting team + * @param lobbyCode the ID for the lobby which the GameManager is responsible for + */ + private void addGameManagerForLobby(Lobby lobby, String lobbyCode) { + Team start = lobby.decideStartingTeam(); + gameService.createGameManager(lobbyCode, start); + } + /** * Adds a player to an existing lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby * @return {@code true} if the player successfully joined, {@code false} otherwise */ public boolean joinLobby(String username, String lobbyCode) { Lobby lobby = lobbyList.get(lobbyCode); if (lobby != null) { + log.info("{}: a player has joined", lobbyCode); return lobby.addPlayer(username); } + log.error("{}: an error occurred when joining lobby", lobbyCode); return false; } + /** + * Registers a recovered lobby into in-memory lobby storage. + * + * @param lobbyCode lobby identifier + * @param lobby recovered lobby instance + */ + public void restoreLobby(String lobbyCode, Lobby lobby) { + lobbyList.put(lobbyCode, lobby); + } + /** * Removes a player from a lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby * @return {@code true} if the player was removed, {@code false} if the lobby does not exist */ @@ -73,38 +111,59 @@ public boolean leaveLobby(String username, String lobbyCode) { Lobby lobby = lobbyList.get(lobbyCode); if (lobby != null) { lobby.removePlayer(username); + log.info("{}: a player left", lobbyCode); return true; } + log.error("{}: an error occurred when leaving", lobbyCode); return false; } /** * Assigns a team and role to a player in a lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby - * @param team the selected team - * @param role the selected role + * @param team the selected team + * @param role the selected role * @return {@code true} if the position was assigned, {@code false} otherwise */ public boolean selectPosition(String username, String lobbyCode, Team team, Role role) { Lobby lobby = lobbyList.get(lobbyCode); if (lobby == null || !lobby.hasPlayer(username) || team == null || role == null) { + log.error("{}: position selection error occurred", lobbyCode); return false; } if (role == Role.SPYMASTER && isSpymasterAlreadyAssigned(lobby, username, team)) { + log.error("{}: position selection error occurred, spymaster is already assigned.", lobbyCode); return false; } lobby.setPlayerTeam(username, team); lobby.setPlayerRole(username, role); + log.info("{}: new role was assigned to player", lobbyCode); return true; } /** - * Retrieves all players in the specified lobby. + * Checks if the lobby still has players after a player leaves and removes the lobby if it is + * empty. + * + * @param lobbyCode the lobby code identifying the lobby + */ + public void checkLobbyStillHasPlayers(String lobbyCode) { + Lobby lobby = lobbyList.get(lobbyCode); + if (lobby.getPlayerList().isEmpty()) { + lobbyList.remove(lobbyCode); + chatService.clearLobbyHistory(lobbyCode); + gameService.removeGame(lobbyCode); + log.info("{}: Lobby is empty, was removed from list.", lobbyCode); + } + } + + /** + * Retrieves all playerList in the specified lobby. * * @param lobbyCode the lobby code identifying the lobby * @return a list of players, or an empty list if the lobby does not exist @@ -114,19 +173,41 @@ public List getPlayers(String lobbyCode) { return lobby != null ? lobby.getPlayerList() : List.of(); } + /** + * Retrieves all playerList in the specified lobby as PlayerDto objects. + * + * @param lobbyCode the lobby code identifying the lobby + * @return a list of PlayerDto objects, or an empty list if the lobby does not exist + */ + public List getPlayersDto(String lobbyCode) { + Lobby lobby = lobbyList.get(lobbyCode); + if (lobby != null) { + return lobby.getPlayerList().stream() + .map( + player -> + new PlayerDto( + player.username(), + lobby.getPlayerTeam(player.username()), + lobby.getPlayerRole(player.username()), + player.isHost())) + .toList(); + } + return List.of(); + } + /** * Checks whether a spymaster is already assigned for the given team in the lobby. * - * @param lobby the lobby to inspect + * @param lobby the lobby to inspect * @param username the username requesting the role - * @param team the team to inspect + * @param team the team to inspect * @return {@code true} if a different player is already the spymaster for that team */ private boolean isSpymasterAlreadyAssigned(Lobby lobby, String username, Team team) { for (Player player : lobby.getPlayerList()) { - if (!player.getUsername().equals(username) - && lobby.getPlayerTeam(player.getUsername()) == team - && lobby.getPlayerRole(player.getUsername()) == Role.SPYMASTER) { + if (!player.username().equals(username) + && lobby.getPlayerTeam(player.username()) == team + && lobby.getPlayerRole(player.username()) == Role.SPYMASTER) { return true; } } @@ -183,4 +264,64 @@ public Role getPlayerRole(String username, String lobbyCode) { } return null; } + + /** + * The service method for starting a game. This creates a game manager object for the lobby and + * checks if the requesting user is liable to start the game. + * + * @param lobbyCode the unique lobby code + * @param username the name of the requesting user + * @return if starting was successful + */ + public boolean startGame(String lobbyCode, String username) { + boolean isStarted = + !lobbyCode.isBlank() && !username.isBlank() && Objects.equals(getHost(lobbyCode), username); + Lobby lobby = lobbyList.get(lobbyCode); + addGameManagerForLobby(lobby, lobbyCode); + + log.info("{}: Game start requested, returning: {}", lobbyCode, isStarted); + return isStarted; + } + + /** + * This method computes the host of a lobby. + * + * @param lobbyCode the unique lobby code + * @return the username of the host + */ + public String getHost(String lobbyCode) { + if (lobbyCode == null || lobbyCode.isBlank()) { + return ""; + } + List players = getPlayers(lobbyCode); + if (players.isEmpty()) { + return ""; + } + for (Player p : players) { + if (p.isHost()) { + return p.username(); + } + } + return ""; + } + + /** + * Checks if the game is started by looking after an existing game manager object. + * + * @param lobbyCode the unique lobby code + * @return whether a game manager exists (@code true or @code false) + */ + public boolean getIsStarted(String lobbyCode) { + return gameService.isGameStarted(lobbyCode); + } + + /** + * Returns all lobbies as serializable snapshots. + * + * @return map of lobby codes to player dto lists + */ + public Map> getLobbySnapshots() { + return lobbyList.keySet().stream() + .collect(java.util.stream.Collectors.toMap(lobbyCode -> lobbyCode, this::getPlayersDto)); + } } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/Board.java b/src/main/java/com/codenames/codenames/backend/playingfield/Board.java index ad38df1c..4e9cefc6 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/Board.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/Board.java @@ -1,6 +1,7 @@ package com.codenames.codenames.backend.playingfield; import com.codenames.codenames.backend.utility.Color; +import java.util.ArrayList; import java.util.List; import lombok.Getter; @@ -23,6 +24,15 @@ public Board( this.cardList = cardGenerator.generateCards(totalWords, red, blue, white, black); } + /** + * Constructs a board from an already existing card list (recovery path). + * + * @param cards recovered cards + */ + public Board(List cards) { + this.cardList = new ArrayList<>(cards); + } + /** * Returns the card object at the position passed. * diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/CardGenerator.java b/src/main/java/com/codenames/codenames/backend/playingfield/CardGenerator.java index f9a88451..af2d1d2b 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/CardGenerator.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/CardGenerator.java @@ -72,8 +72,8 @@ public List generateCards(int totalWords, int red, int blue, int white, in List colorList = new ArrayList<>(); addColorToList(colorList, red, Color.RED); addColorToList(colorList, blue, Color.BLUE); - addColorToList(colorList, white, Color.WHITE); - addColorToList(colorList, black, Color.BLACK); + addColorToList(colorList, white, Color.NEUTRAL); + addColorToList(colorList, black, Color.ASSASSIN); List cardList = new ArrayList<>(); List wordList = pickWords(totalWords); diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java index db103ea2..81aca161 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java @@ -2,16 +2,19 @@ import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import java.util.List; import lombok.Getter; /** - * Manages state and initialization of a game. + * This class handles the setup of the board and interaction, by providing methods to interact with + * the game's state. * - *

This class handles the setup of the board and interaction, by providing methods to interact - * with the game's state. + *

Additionally, it keeps track of the points, turn and handles the early determining the winner */ public class GameManager { @@ -24,17 +27,21 @@ public class GameManager { private final Board board; @Getter private int currentRedFound = 0; @Getter private int currentBlueFound = 0; - private Color winner; + private Team winner; private final ClueValidationService clueValidationService; @Getter private Clue currentClue; @Getter private int remainingGuesses; + @Getter private Team currentTurn; + @Getter private Role currentPhase; + /** * Constructor for a new GameManager and initializes the playing board. * * @param startingTeam the team that goes first will get an extra card * @param cardGenerator the utility used to generate the cards for the game + * @param clueValidationService the utility used to validate clues * @throws IllegalArgumentException if team is null, white or black */ public GameManager( @@ -42,7 +49,11 @@ public GameManager( if (startingTeam == null) { throw new IllegalArgumentException("startingTeam cannot be null"); } + this.currentTurn = startingTeam; + this.currentPhase = Role.SPYMASTER; // we hard code spymaster since game has to start with them + this.clueValidationService = clueValidationService; + if (startingTeam == Team.RED) { this.redCards = 9; this.blueCards = 8; @@ -50,10 +61,69 @@ public GameManager( this.redCards = 8; this.blueCards = 9; } + this.board = new Board(cardGenerator, TOTAL_CARDS, redCards, blueCards, WHITE_CARDS, BLACK_CARDS); } + /** + * Constructor used by recovery logic to rebuild an already running game state. + * + * @param state bundled recovery state + * @param clueValidationService clue validation service + */ + public GameManager( + GameStateDataTransferObject state, ClueValidationService clueValidationService) { + if (state.cardList() == null || state.cardList().isEmpty()) { + throw new IllegalArgumentException("cards cannot be null or empty"); + } + if (state.currentTurn() == null || state.currentPhase() == null) { + throw new IllegalArgumentException("current turn and phase cannot be null"); + } + + this.currentTurn = state.currentTurn(); + this.currentPhase = state.currentPhase(); + this.winner = state.winner(); + this.currentClue = + state.currentClue() == null + ? null + : new Clue(state.currentClue().word(), state.currentClue().guessAmount()); + this.clueValidationService = clueValidationService; + + List cards = state.cardList().stream().map(this::toCard).toList(); + + this.currentRedFound = countGuessedByColor(cards, Color.RED); + this.currentBlueFound = countGuessedByColor(cards, Color.BLUE); + + this.redCards = countCardsByColor(cards, Color.RED); + this.blueCards = countCardsByColor(cards, Color.BLUE); + this.board = new Board(cards); + } + + private Card toCard(CardDataTransferObject cardDto) { + Card card = new Card(cardDto.word(), cardDto.color()); + if (cardDto.isGuessed()) { + card.setIsGuessedTrue(); + } + return card; + } + + private int countGuessedByColor(List cards, Color color) { + return (int) + cards.stream().filter(card -> card.isGuessed() && card.getColor() == color).count(); + } + + /** + * Counts cards of a specific color within the current board snapshot. + * + * @param cards recovered cards + * @param color color to count + * @return number of cards with the requested color + */ + private int countCardsByColor(List cards, Color color) { + return (int) cards.stream().filter(card -> card.getColor() == color).count(); + } + /** * Returns the current list of cards in a board. * @@ -77,9 +147,8 @@ public Color checkColor(int position) { * Updates the score based on the color passed. If black card is found, opposing team wins. * * @param cardColor the color of the card - * @param currentTurn the current team's turn */ - private void updateScore(Color cardColor, Color currentTurn) { + private void updateScore(Color cardColor) { switch (cardColor) { case RED: currentRedFound++; @@ -87,11 +156,11 @@ private void updateScore(Color cardColor, Color currentTurn) { case BLUE: currentBlueFound++; break; - case BLACK: - if (currentTurn == Color.RED) { - this.winner = Color.BLUE; + case ASSASSIN: + if (this.currentTurn == Team.RED) { + this.winner = Team.BLUE; } else { - this.winner = Color.RED; + this.winner = Team.RED; } break; default: @@ -104,15 +173,15 @@ private void updateScore(Color cardColor, Color currentTurn) { * * @return the winning color is returned or null if no team has won */ - public Color getWinner() { + public Team getWinner() { if (this.winner != null) { return this.winner; } if (currentRedFound >= redCards) { - return Color.RED; + return Team.RED; } if (currentBlueFound >= blueCards) { - return Color.BLUE; + return Team.BLUE; } return null; } @@ -121,10 +190,10 @@ public Color getWinner() { * Changes the guessed state of a card and updates the score if necessary. * * @param position the position of the card that is selected by the player - * @param currentTurn the team whose turn it currently is + * @param callingTeam the team that called this method * @throws IllegalStateException if game over, card already flipped, no more guesses */ - public void flipCard(int position, Color currentTurn) { + public void flipCard(int position, Team callingTeam) { if (getWinner() != null) { throw new IllegalStateException("Winner is already set"); } @@ -135,22 +204,36 @@ public void flipCard(int position, Color currentTurn) { clearClue(); throw new IllegalStateException("No more guesses."); } + + checkCorrectTurn(callingTeam, Role.OPERATIVE); + this.remainingGuesses--; this.board.setGuessed(position); Color currentColor = this.board.checkColor(position); - updateScore(currentColor, currentTurn); + updateScore(currentColor); + + boolean opponentOrWhiteCard = + (currentTurn == Team.RED && currentColor != Color.RED) + || (currentTurn == Team.BLUE && currentColor != Color.BLUE); + + if (opponentOrWhiteCard || this.remainingGuesses == 0) { + advanceTurn(); + } } /** * Submits a clue and updates remaining guesses. * * @param clue the clue object containing word and guess amount + * @param callingTeam the team that called this method * @throws IllegalArgumentException if clue is: null, empty, spaces, or word is on the board */ - public void submitClue(Clue clue) { + public void submitClue(Clue clue, Team callingTeam) { + checkCorrectTurn(callingTeam, Role.SPYMASTER); if (clueValidationService.validateWord(this.board, clue.word())) { this.currentClue = clue; this.remainingGuesses = clue.guessAmount(); + advanceTurn(); } else { throw new IllegalArgumentException("Clue is invalid, cannot be a word that is on the board!"); } @@ -173,4 +256,52 @@ public String getCurrentClueWord() { } return currentClue.word(); } + + /** Changes the color of what team is at turn. */ + private void nextTeamColor() { + if (currentTurn == Team.RED) { + currentTurn = Team.BLUE; + } else { + currentTurn = Team.RED; + } + } + + /** + * Is called by relevant turn based methods. Class holds the current Team and Phase, when we call + * this method, based on the current turn, we swap to the opposite turn/ phase. Since after + * spymaster is done, the same team color is still at turn, and we can simply switch to the + * operative phase. If we are Operative, we have to additionally switch the next team color and + * clear the clue from our spymaster. + */ + public void advanceTurn() { + if (currentPhase == Role.SPYMASTER) { + currentPhase = Role.OPERATIVE; + } else { + currentPhase = Role.SPYMASTER; + nextTeamColor(); + clearClue(); + } + } + + /** + * Voluntarily pass turn early before all guesses are used up. + * + * @param callingTeam the current team calling the method. + */ + public void passTurn(Team callingTeam) { + checkCorrectTurn(callingTeam, Role.OPERATIVE); + advanceTurn(); + } + + /** + * Helper method to check if the current team calling a method is allowed to do so. + * + * @param team the team of who is calling the method + * @param role the role of who is calling the method + */ + private void checkCorrectTurn(Team team, Role role) { + if (team != currentTurn || role != currentPhase) { + throw new IllegalStateException("Not your turn/ role"); + } + } } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java new file mode 100644 index 00000000..63b468ab --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -0,0 +1,45 @@ +package com.codenames.codenames.backend.playingfield; + +import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Team; +import org.springframework.stereotype.Component; + +/** Generates GameManager instances to be used by GameService. */ +@Component +public class GameManagerFactory { + private final CardGenerator cardGenerator; + private final ClueValidationService clueValidationService; + + /** + * Initialized the factory with utility services injected via Spring. + * + * @param cardGenerator utility service to generate cards + * @param clueValidationService utility service to validate clues + */ + public GameManagerFactory( + CardGenerator cardGenerator, ClueValidationService clueValidationService) { + this.cardGenerator = cardGenerator; + this.clueValidationService = clueValidationService; + } + + /** + * Creates a GameManager object that is used in GameService. + * + * @param startingTeam the team that starts the game + * @return the GameManager object to be used in GameService + */ + public GameManager create(Team startingTeam) { + return new GameManager(startingTeam, cardGenerator, clueValidationService); + } + + /** + * Recreates a {@link GameManager} from a persisted snapshot. + * + * @param snapshot persisted game state snapshot + * @return restored game manager + */ + public GameManager createFromSnapshot(GameStateDataTransferObject snapshot) { + return new GameManager(snapshot, clueValidationService); + } +} diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java new file mode 100644 index 00000000..a3ec0b52 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -0,0 +1,159 @@ +package com.codenames.codenames.backend.playingfield; + +import com.codenames.codenames.backend.clue.Clue; +import com.codenames.codenames.backend.serialization.DataTransferObjectService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Team; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Service; + +/** + * Service class for the game. The class stores an instance of GameManager for each lobby. This also + * exposes the methods of the GameManager, so that the websocket controllers can have message + * mappings to allow frontend to interact with the backend. + */ +@Service +public class GameService { + + private final Map games = new ConcurrentHashMap<>(); + private final GameManagerFactory gameManagerFactory; + private final DataTransferObjectService dtoService; + + /** + * Constructor for a GameService object. + * + * @param gameManagerFactory the factory responsible for generating GameManagers + */ + public GameService(GameManagerFactory gameManagerFactory, DataTransferObjectService dtoService) { + this.gameManagerFactory = gameManagerFactory; + this.dtoService = dtoService; + } + + /** + * We have a has map with lobbyID as the key. This method adds a GM if the key does not already + * exist. + * + * @param lobbyCode the lobbyID that serves as the key + * @param startingTeam the starting team required to initialize a GM + */ + public void createGameManager(String lobbyCode, Team startingTeam) { + games.computeIfAbsent(lobbyCode, key -> gameManagerFactory.create(startingTeam)); + } + + /** + * This method serves as a deletion of an entry in the hashmap once a lobby is no longer needed. + * + * @param lobbyCode the lobbyID that serves as a key to identify which GM to delete + */ + public void removeGame(String lobbyCode) { + games.remove(lobbyCode); + } + + /** + * Registers a recovered {@link GameManager} for a lobby after backend restart. + * + * @param lobbyCode lobby identifier + * @param gameManager recovered game manager + */ + public void restoreGameManager(String lobbyCode, GameManager gameManager) { + games.put(lobbyCode, gameManager); + } + + /** + * Helper method to retrieve a GM object from the hash map. + * + * @param lobbyCode the lobbyID that serves as a key to identify which GM to access + * @return the GM specified by the lobbyID + */ + private GameManager getGame(String lobbyCode) { + if (games.get(lobbyCode) == null) { + throw new IllegalStateException("GameManager does not exist for lobby: " + lobbyCode); + } + return games.get(lobbyCode); + } + + /** + * Retrieves the current GameManager for a lobby. + * + * @param lobbyCode lobby identifier + * @return the active GameManager + */ + public GameManager getGameState(String lobbyCode) { + return getGame(lobbyCode); + } + + /** + * The exposed clue submission method from GM that is accessed by frontend via websockets. + * + * @param lobbyCode the lobbyID that serves as a key to identify which GM to access + * @param clue the clue object that needs to be validated and added to GM + * @param callingTeam the team who called to ensure the calling team is at turn + */ + public void submitClue(String lobbyCode, Clue clue, Team callingTeam) { + GameManager gm = getGame(lobbyCode); + gm.submitClue(clue, callingTeam); + } + + /** + * The exposed card flipping method from GM that is accessed by frontend via websockets. + * + * @param lobbyCode the lobbyID that serves as a key to identify which GM to access + * @param position the position of which card is supposed to flipped on the board + * @param callingTeam the team who called to ensure the calling team is at turn + */ + public void flipCard(String lobbyCode, int position, Team callingTeam) { + GameManager gm = getGame(lobbyCode); + gm.flipCard(position, callingTeam); + } + + /** + * The exposed early turn ending method from GM that is accessed by frontend via websockets. + * + * @param lobbyCode the lobbyID that serves as a key to identify which GM to access + * @param callingTeam the team who called to ensure the calling team is at turn + */ + public void passTurn(String lobbyCode, Team callingTeam) { + GameManager gm = getGame(lobbyCode); + gm.passTurn(callingTeam); + } + + /** + * Maps the current game state into a @link GameStateTransferObject. + * + * @param lobbyCode the unique lobby code + * @return the mapped game state transfer object + */ + public GameStateDataTransferObject getCurrentGameState(String lobbyCode) { + GameManager gm = getGame(lobbyCode); + return dtoService.createGameStateDataTransferObject( + gm, gm.getCurrentTurn(), gm.getCurrentPhase()); + } + + /** + * This method uses the private method getGame to check if a game is already started via the + * existence of a game manager. + * + * @param lobbyCode the lobbyCode of the lobby + * @return if the game manager for the lobby already exists aka the game is started + */ + public boolean isGameStarted(String lobbyCode) { + try { + getGame(lobbyCode); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Returns all active games as serializable snapshots. + * + * @return map of lobby codes to game state dto snapshots + */ + public Map getGameSnapshots() { + return games.keySet().stream() + .collect( + java.util.stream.Collectors.toMap(lobbyCode -> lobbyCode, this::getCurrentGameState)); + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java new file mode 100644 index 00000000..1c58c2ca --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java @@ -0,0 +1,99 @@ +package com.codenames.codenames.backend.recovery; + +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.concurrent.locks.ReentrantLock; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * JSON-backed storage for persisted system snapshots. + * + *

Writes are synchronized and performed via temporary-file replace to reduce corruption risk. + */ +@Component +@Getter +public class JsonStateStore { + + private final ObjectMapper objectMapper; + private final Path stateFilePath; + private final ReentrantLock ioLock = new ReentrantLock(); + + /** + * Creates a new state store with configured target file path. + * + * @param objectMapper mapper used for JSON serialization + * @param stateFile configured path to the persisted state file + */ + public JsonStateStore( + ObjectMapper objectMapper, @Value("${app.state-file:data/state.json}") String stateFile) { + this.objectMapper = objectMapper; + this.stateFilePath = Path.of(stateFile); + } + + /** + * Persists the full system snapshot atomically. + * + * @param snapshot full snapshot to store + */ + public void save(SystemSnapshot snapshot) { + ioLock.lock(); + try { + Path parentDirectory = stateFilePath.getParent(); + if (parentDirectory != null) { + Files.createDirectories(parentDirectory); + } + + Path tempFilePath = stateFilePath.resolveSibling(stateFilePath.getFileName() + ".tmp"); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempFilePath.toFile(), snapshot); + + moveAtomically(tempFilePath, stateFilePath); + } catch (IOException exception) { + throw new IllegalStateException("Failed to persist state snapshot.", exception); + } finally { + ioLock.unlock(); + } + } + + /** + * Loads the persisted system snapshot when present. + * + * @return optional snapshot + */ + public Optional load() { + ioLock.lock(); + try { + if (!Files.exists(stateFilePath) || Files.size(stateFilePath) == 0L) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(stateFilePath.toFile(), SystemSnapshot.class)); + } catch (IOException exception) { + throw new IllegalStateException("Failed to load persisted state snapshot.", exception); + } finally { + ioLock.unlock(); + } + } + + /** + * Replaces target file with source file using atomic move where supported. + * + * @param source temporary source file + * @param target final target file + * @throws IOException when move fails + */ + private void moveAtomically(Path source, Path target) throws IOException { + try { + Files.move( + source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java new file mode 100644 index 00000000..17bf2406 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java @@ -0,0 +1,52 @@ +package com.codenames.codenames.backend.recovery; + +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import org.springframework.stereotype.Service; + +/** + * Persists the current runtime state of the backend. + * + *

Collects lobby and game snapshots and stores them through the configured {@link + * JsonStateStore} to enable recovery after application restart. + */ +@Service +public class SystemStatePersistenceService { + + private final JsonStateStore stateStore; + private final LobbyService lobbyService; + private final GameService gameService; + + /** + * Creates a persistence service responsible for storing backend state snapshots. + * + * @param stateStore JSON storage implementation + * @param lobbyService service providing lobby snapshots + * @param gameService service providing game snapshots + */ + public SystemStatePersistenceService( + JsonStateStore stateStore, LobbyService lobbyService, GameService gameService) { + + this.stateStore = stateStore; + this.lobbyService = lobbyService; + this.gameService = gameService; + } + + /** + * Persists the current backend state. + * + *

Creates a {@link SystemSnapshot} containing all active lobbies and games and stores it using + * the configured {@link JsonStateStore}. + */ + public void persistCurrentState() { + + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + lobbyService.getLobbySnapshots(), + gameService.getGameSnapshots()); + + stateStore.save(snapshot); + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java new file mode 100644 index 00000000..1425196d --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java @@ -0,0 +1,137 @@ +package com.codenames.codenames.backend.recovery; + +import com.codenames.codenames.backend.lobby.Lobby; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameManager; +import com.codenames.codenames.backend.playingfield.GameManagerFactory; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** Restores persisted lobbies and games into in-memory runtime services on backend startup. */ +@Slf4j +@Service +public class SystemStateRecoveryService { + + private final JsonStateStore stateStore; + private final LobbyService lobbyService; + private final GameService gameService; + private final GameManagerFactory gameManagerFactory; + + /** + * Creates a recovery service. + * + * @param stateStore JSON state store used for loading snapshots + * @param lobbyService lobby runtime service + * @param gameService game runtime service + * @param gameManagerFactory factory used to rebuild game managers from snapshots + */ + public SystemStateRecoveryService( + JsonStateStore stateStore, + LobbyService lobbyService, + GameService gameService, + GameManagerFactory gameManagerFactory) { + this.stateStore = stateStore; + this.lobbyService = lobbyService; + this.gameService = gameService; + this.gameManagerFactory = gameManagerFactory; + } + + /** Loads and restores persisted state at startup when a compatible snapshot exists. */ + @jakarta.annotation.PostConstruct + public void recoverOnStartup() { + stateStore + .load() + .ifPresent( + snapshot -> { + if (snapshot.schemaVersion() != SystemSnapshot.CURRENT_SCHEMA_VERSION) { + log.warn( + "Skipping snapshot restore due to schema mismatch. Found {}, expected {}.", + snapshot.schemaVersion(), + SystemSnapshot.CURRENT_SCHEMA_VERSION); + return; + } + restoreLobbies(snapshot.lobbies()); + restoreGames(snapshot.games()); + }); + } + + /** + * Restores all lobby snapshots into {@link LobbyService}. + * + * @param lobbySnapshots persisted lobby player lists keyed by lobby code + */ + private void restoreLobbies(Map> lobbySnapshots) { + if (lobbySnapshots == null || lobbySnapshots.isEmpty()) { + return; + } + for (Map.Entry> entry : lobbySnapshots.entrySet()) { + Lobby restoredLobby = buildLobby(entry.getKey(), entry.getValue()); + if (restoredLobby != null) { + lobbyService.restoreLobby(entry.getKey(), restoredLobby); + } + } + } + + /** + * Restores all game snapshots into {@link GameService}. + * + * @param gameSnapshots persisted game states keyed by lobby code + */ + private void restoreGames(Map gameSnapshots) { + if (gameSnapshots == null || gameSnapshots.isEmpty()) { + return; + } + for (Map.Entry entry : gameSnapshots.entrySet()) { + GameManager restoredGame = gameManagerFactory.createFromSnapshot(entry.getValue()); + gameService.restoreGameManager(entry.getKey(), restoredGame); + } + } + + /** + * Builds a runtime lobby from a persisted lobby snapshot. + * + * @param lobbyCode target lobby code + * @param players persisted lobby players + * @return rebuilt lobby, or {@code null} when player data is invalid + */ + private Lobby buildLobby(String lobbyCode, List players) { + if (players == null || players.isEmpty()) { + log.warn("Skipping restore for lobby {} due to missing player data.", lobbyCode); + return null; + } + + List validPlayers = + players.stream() + .filter(player -> player.username() != null && !player.username().isBlank()) + .sorted(Comparator.comparing(PlayerDto::isHost).reversed()) + .toList(); + + if (validPlayers.isEmpty()) { + return null; + } + + PlayerDto host = validPlayers.get(0); + Lobby lobby = new Lobby(lobbyCode, host.username()); + + for (PlayerDto player : validPlayers) { + if (!player.username().equals(host.username())) { + lobby.addPlayer(player.username(), player.isHost()); + } + if (player.team() != null) { + lobby.setPlayerTeam(player.username(), player.team()); + } + if (player.role() != null) { + lobby.setPlayerRole(player.username(), player.role()); + } + } + + return lobby; + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java new file mode 100644 index 00000000..3ae69bb4 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java @@ -0,0 +1,21 @@ +package com.codenames.codenames.backend.recovery.snapshot; + +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import java.util.List; +import java.util.Map; + +/** + * Root snapshot aggregate for persisted backend runtime state. + * + * @param schemaVersion persisted schema version + * @param lobbies lobby player lists keyed by lobby code + * @param games game states keyed by lobby code + */ +public record SystemSnapshot( + int schemaVersion, + Map> lobbies, + Map games) { + + public static final int CURRENT_SCHEMA_VERSION = 2; +} diff --git a/src/main/java/com/codenames/codenames/backend/serialization/CardDataTransferObject.java b/src/main/java/com/codenames/codenames/backend/serialization/CardDataTransferObject.java index 8174a249..e367571e 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/CardDataTransferObject.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/CardDataTransferObject.java @@ -1,5 +1,7 @@ package com.codenames.codenames.backend.serialization; +import com.codenames.codenames.backend.utility.Color; + /** * Represents the state of a single card, for JSON serialization. * @@ -7,4 +9,4 @@ * @param color the color of the card (could also be "hidden") * @param isGuessed the guess state of the card */ -public record CardDataTransferObject(String word, String color, boolean isGuessed) {} +public record CardDataTransferObject(String word, Color color, boolean isGuessed) {} diff --git a/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java b/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java index 1cbec083..f54d3442 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java @@ -1,8 +1,11 @@ package com.codenames.codenames.backend.serialization; +import com.codenames.codenames.backend.game.dto.ClueDto; import com.codenames.codenames.backend.playingfield.Card; import com.codenames.codenames.backend.playingfield.GameManager; +import com.codenames.codenames.backend.utility.Color; import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Service; @@ -15,17 +18,10 @@ public class DataTransferObjectService { * Helper method to create a card DTO with the correct visibility based on role and guess state. * * @param card card object from the board - * @param role role of the player, which determines the visibility of the card's color * @return the card DTO for the game state DTO */ - private CardDataTransferObject createCardDataTransferObject(Card card, Role role) { - String displayColor; - - if (role == Role.SPYMASTER || card.isGuessed()) { - displayColor = card.getColor().toString(); - } else { - displayColor = "HIDDEN"; - } + private CardDataTransferObject createCardDataTransferObject(Card card) { + Color displayColor = card.getColor(); return new CardDataTransferObject(card.getWord(), displayColor, card.isGuessed()); } @@ -33,31 +29,32 @@ private CardDataTransferObject createCardDataTransferObject(Card card, Role role * Creates the game state DTO that needs to be serialized into JSON. * * @param gameManager the game manager that holds the state of the game - * @param role the role of the player who requires the DTO * @param currentTurn the current turn * @return a DTO of the current game state */ public GameStateDataTransferObject createGameStateDataTransferObject( - GameManager gameManager, Role role, String currentTurn) { + GameManager gameManager, Team currentTurn, Role currentPhase) { List cardList = gameManager.getCardList(); List cardDataTransferObject = new ArrayList<>(); for (Card card : cardList) { - cardDataTransferObject.add(createCardDataTransferObject(card, role)); + cardDataTransferObject.add(createCardDataTransferObject(card)); } - String winner; - if (gameManager.getWinner() == null) { - winner = null; - } else { - winner = gameManager.getWinner().toString(); + if (gameManager.getCurrentClue() == null) { + return new GameStateDataTransferObject( + gameManager.getWinner(), + currentTurn, + currentPhase, + null, + cardDataTransferObject); } + String word = gameManager.getCurrentClue().word(); + int guessAmount = gameManager.getCurrentClue().guessAmount(); return new GameStateDataTransferObject( - winner, + gameManager.getWinner(), currentTurn, - gameManager.getCurrentRedFound(), - gameManager.getCurrentBlueFound(), - gameManager.getCurrentClueWord(), - gameManager.getRemainingGuesses(), - cardDataTransferObject); + currentPhase, + new ClueDto(word, guessAmount), + cardDataTransferObject); } } diff --git a/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java b/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java index 16b03f3f..95f9c982 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java @@ -1,23 +1,22 @@ package com.codenames.codenames.backend.serialization; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; import java.util.List; /** * Represents the current state of the game to be serialized into JSON. * - * @param winner the winner - * @param currentTurn the current team who is allowed to make a move - * @param currentRedFound the amount of red cards revealed - * @param currentBlueFound the amount of red cards revealed - * @param currentClue the word of the clue - * @param remainingGuesses the amount of guesses - * @param cardList the cards on the board + * @param winner the winner + * @param currentTurn the current team who is allowed to make a move + * @param currentPhase the current phase (spymaster or operative) + * @param currentClue the current clue object, consisting of word and amount of guesses + * @param cardList the cards on the board */ public record GameStateDataTransferObject( - String winner, - String currentTurn, - int currentRedFound, - int currentBlueFound, - String currentClue, - int remainingGuesses, + Team winner, + Team currentTurn, + Role currentPhase, + ClueDto currentClue, List cardList) {} diff --git a/src/main/java/com/codenames/codenames/backend/utility/Color.java b/src/main/java/com/codenames/codenames/backend/utility/Color.java index ab654f4b..37c4b6f6 100644 --- a/src/main/java/com/codenames/codenames/backend/utility/Color.java +++ b/src/main/java/com/codenames/codenames/backend/utility/Color.java @@ -6,6 +6,6 @@ public enum Color { RED, BLUE, - WHITE, - BLACK + NEUTRAL, + ASSASSIN } diff --git a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java index 6fe3fc9a..3cec2aee 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java @@ -1,7 +1,9 @@ package com.codenames.codenames.backend.websocket; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameService; import java.util.List; +import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -13,10 +15,12 @@ *

Processes client messages (e.g. join requests), coordinates with {@link LobbyService}, and * broadcasts updates to subscribed clients. */ +@Slf4j @Controller public class GameController { private final LobbyService lobbyService; + private final GameService gameService; private final SimpMessagingTemplate messagingTemplate; private final SessionRegistry sessionRegistry; @@ -24,14 +28,17 @@ public class GameController { * Creates a new {@code GameController}. * * @param lobbyService the service handling lobby operations + * @param gameService the service handling game state retrieval * @param messagingTemplate the messaging template used for broadcasting updates * @param sessionRegistry the registry managing WebSocket sessions */ public GameController( LobbyService lobbyService, + GameService gameService, SimpMessagingTemplate messagingTemplate, SessionRegistry sessionRegistry) { this.lobbyService = lobbyService; + this.gameService = gameService; this.messagingTemplate = messagingTemplate; this.sessionRegistry = sessionRegistry; } @@ -49,25 +56,33 @@ public GameController( public void join(JoinMessage message, SimpMessageHeaderAccessor headerAccessor) { String sessionId = headerAccessor.getSessionId(); + try { + if (sessionId == null && headerAccessor.getSessionAttributes() != null) { + sessionId = (String) headerAccessor.getSessionAttributes().get("sessionId"); + } - if (sessionId == null && headerAccessor.getSessionAttributes() != null) { - sessionId = (String) headerAccessor.getSessionAttributes().get("sessionId"); - } - - if (sessionId == null) { - return; + if (sessionId == null) { + return; + } + } catch (NullPointerException e) { + log.error(e.getMessage()); } boolean joined = lobbyService.joinLobby(message.getName(), message.getCode()); + boolean reconnect = + lobbyService.getPlayers(message.getCode()).stream() + .anyMatch(player -> player.username().equals(message.getName())); - if (!joined) { + if (!joined && !reconnect) { messagingTemplate.convertAndSend("/topic/errors/" + sessionId, "Join failed"); return; } sessionRegistry.register(sessionId, message.getName(), message.getCode()); + sendPlayerUpdate(message.getCode()); + sendGameStateUpdate(message.getCode()); } /** @@ -76,8 +91,17 @@ public void join(JoinMessage message, SimpMessageHeaderAccessor headerAccessor) * @param code the lobby code identifying the lobby */ private void sendPlayerUpdate(String code) { - List players = lobbyService.getPlayers(code).stream().map(Player::getUsername).toList(); + List players = lobbyService.getPlayers(code).stream().map(Player::username).toList(); messagingTemplate.convertAndSend("/topic/lobby/" + code, players); } + + /** + * Sends the current game state to all clients subscribed to the lobby game topic. + * + * @param code the lobby code identifying the game + */ + private void sendGameStateUpdate(String code) { + messagingTemplate.convertAndSend("/topic/game/" + code, gameService.getCurrentGameState(code)); + } } diff --git a/src/main/java/com/codenames/codenames/backend/websocket/Player.java b/src/main/java/com/codenames/codenames/backend/websocket/Player.java index 5acedb38..38b6cd83 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/Player.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/Player.java @@ -1,22 +1,9 @@ package com.codenames.codenames.backend.websocket; -import lombok.Getter; - /** * Represents a player connected to the system. * *

A player is identified by a username and may be associated with a WebSocket session. */ -@Getter -public class Player { - private final String username; - - /** - * Creates a new player. - * - * @param username the player's username - */ - public Player(String username) { - this.username = username; - } +public record Player(String username, boolean isHost) { } diff --git a/src/main/java/com/codenames/codenames/backend/websocket/WebSocketEventListener.java b/src/main/java/com/codenames/codenames/backend/websocket/WebSocketEventListener.java index 1389ae21..c2982111 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/WebSocketEventListener.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/WebSocketEventListener.java @@ -1,45 +1,32 @@ package com.codenames.codenames.backend.websocket; -import com.codenames.codenames.backend.lobby.services.LobbyService; -import java.util.List; import org.springframework.context.event.EventListener; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.SessionDisconnectEvent; /** * Listener for WebSocket lifecycle events. * - *

Handles client disconnections by removing players from lobbies, cleaning up session mappings, - * and notifying remaining clients. + *

Handles client disconnections by cleaning up session mappings. */ @Component public class WebSocketEventListener { private final SessionRegistry sessionRegistry; - private final LobbyService lobbyService; - private final SimpMessagingTemplate messagingTemplate; /** * Creates a new {@code WebSocketEventListener}. * * @param sessionRegistry the registry managing WebSocket sessions - * @param lobbyService the service handling lobby operations - * @param messagingTemplate the messaging template used for broadcasting updates */ - public WebSocketEventListener( - SessionRegistry sessionRegistry, - LobbyService lobbyService, - SimpMessagingTemplate messagingTemplate) { + public WebSocketEventListener(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; - this.lobbyService = lobbyService; - this.messagingTemplate = messagingTemplate; } /** * Handles a WebSocket disconnect event. * - *

Removes the disconnected player from the lobby, cleans up session data, and broadcasts the - * updated player list. + *

Removes transient WebSocket session mappings while preserving lobby membership for + * reconnecting. * * @param event the disconnect event containing session information */ @@ -48,19 +35,10 @@ public void handleDisconnect(SessionDisconnectEvent event) { String sessionId = event.getSessionId(); - String username = sessionRegistry.getUser(sessionId); - String lobbyCode = sessionRegistry.getLobby(sessionId); - - if (username == null || lobbyCode == null) { + if (sessionRegistry.getUser(sessionId) == null || sessionRegistry.getLobby(sessionId) == null) { return; } - lobbyService.leaveLobby(username, lobbyCode); sessionRegistry.remove(sessionId); - - List players = - lobbyService.getPlayers(lobbyCode).stream().map(Player::getUsername).toList(); - - messagingTemplate.convertAndSend("/topic/lobby/" + lobbyCode, players); } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 391e93fe..67237b60 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,4 +3,4 @@ spring: name: Codenames_Backend app: - allowed-origins: "http://localhost:8080,http://10.0.2.2:8080" \ No newline at end of file + allowed-origins: "http://localhost:8080,http://10.0.2.2:8080" \ No newline at end of file diff --git a/src/test/java/com/codenames/codenames/backend/chat/ChatControllerTest.java b/src/test/java/com/codenames/codenames/backend/chat/ChatControllerTest.java index 31bfd57b..6a18a085 100644 --- a/src/test/java/com/codenames/codenames/backend/chat/ChatControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/chat/ChatControllerTest.java @@ -37,7 +37,7 @@ void setUp() { lobbyService = mock(LobbyService.class); headerAccessor = mock(SimpMessageHeaderAccessor.class); - chatController = new ChatController(chatService, lobbyService, sessionRegistry); + chatController = new ChatController(chatService, lobbyService); chatDto = new ChatDto("TestName", "TestMessage", ChatMessageType.CHAT); @@ -51,46 +51,19 @@ void setUp() { @Test void testSendLobbyMessage_valid() { - chatController.sendLobbyMessage(lobbyId, chatDto, headerAccessor); + chatController.sendLobbyMessage(lobbyId, chatDto); - ChatDto verifiedChatDto = new ChatDto(realUsername, chatDto.content(), chatDto.type()); + ChatDto verifiedChatDto = new ChatDto(chatDto.senderUsername(), chatDto.content(), chatDto.type()); verify(chatService, times(1)).processMessage(lobbyId, "LOBBY", "", verifiedChatDto); } - @Test - void testSendLobbyMessage_nullUsername() { - when(sessionRegistry.getUser(sessionId)).thenReturn(null); - - assertThrows( - IllegalStateException.class, - () -> chatController.sendLobbyMessage(lobbyId, chatDto, headerAccessor)); - } - - @Test - void testSendLobbyMessage_wrongLobby() { - when(sessionRegistry.getLobby(sessionId)).thenReturn("differentLobby"); - - assertThrows( - IllegalStateException.class, - () -> chatController.sendLobbyMessage(lobbyId, chatDto, headerAccessor)); - } - - @Test - void testSendLobbyMessage_nullLobby() { - when(sessionRegistry.getLobby(sessionId)).thenReturn("differentLobby"); - - assertThrows( - IllegalStateException.class, - () -> chatController.sendLobbyMessage(null, chatDto, headerAccessor)); - } - @Test void testSendTeamMessage_valid() { - when(lobbyService.getPlayerTeam(realUsername, lobbyId)).thenReturn(redTeam); + when(lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId)).thenReturn(redTeam); - chatController.sendTeamMessage(lobbyId, redTeam, chatDto, headerAccessor); + chatController.sendTeamMessage(lobbyId, redTeam, chatDto); - ChatDto verifiedChatDto = new ChatDto(realUsername, chatDto.content(), chatDto.type()); + ChatDto verifiedChatDto = new ChatDto(chatDto.senderUsername(), chatDto.content(), chatDto.type()); verify(chatService, times(1)).processMessage(lobbyId, "TEAM_RED", "/RED", verifiedChatDto); } @@ -100,38 +73,48 @@ void testSendTeamMessage_sendToDifferentTeam_throwException() { assertThrows( IllegalStateException.class, - () -> chatController.sendTeamMessage(lobbyId, redTeam, chatDto, headerAccessor)); + () -> chatController.sendTeamMessage(lobbyId, redTeam, chatDto)); } @Test void testSendTeamOperativeMessage_valid() { - when(lobbyService.getPlayerTeam(realUsername, lobbyId)).thenReturn(blueTeam); - when(lobbyService.getPlayerRole(realUsername, lobbyId)).thenReturn(Role.OPERATIVE); + when(lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId)).thenReturn(blueTeam); + when(lobbyService.getPlayerRole(chatDto.senderUsername(), lobbyId)).thenReturn(Role.OPERATIVE); - chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto, headerAccessor); + chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto); - ChatDto verifiedChatDto = new ChatDto(realUsername, chatDto.content(), chatDto.type()); + ChatDto verifiedChatDto = new ChatDto(chatDto.senderUsername(), chatDto.content(), chatDto.type()); verify(chatService, times(1)) .processMessage(lobbyId, "OPERATIVE_BLUE", "/BLUE/operative", verifiedChatDto); } @Test void testSendTeamOperativeMessage_wrongRoleCorrectTeam() { - when(lobbyService.getPlayerTeam(realUsername, lobbyId)).thenReturn(blueTeam); - when(lobbyService.getPlayerRole(realUsername, lobbyId)).thenReturn(Role.SPYMASTER); + when(lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId)).thenReturn(blueTeam); + when(lobbyService.getPlayerRole(chatDto.senderUsername(), lobbyId)).thenReturn(Role.SPYMASTER); assertThrows( IllegalStateException.class, - () -> chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto, headerAccessor)); + () -> chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto)); } @Test void testSendTeamOperativeMessage_wrongTeamCorrectRole() { - when(lobbyService.getPlayerTeam(realUsername, lobbyId)).thenReturn(redTeam); - when(lobbyService.getPlayerRole(realUsername, lobbyId)).thenReturn(Role.OPERATIVE); + when(lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId)).thenReturn(redTeam); + when(lobbyService.getPlayerRole(chatDto.senderUsername(), lobbyId)).thenReturn(Role.OPERATIVE); + + assertThrows( + IllegalStateException.class, + () -> chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto)); + } + + @Test + void testSendTeamOperativeMessage_wrongRoleWrongTeam() { + when(lobbyService.getPlayerTeam(chatDto.senderUsername(), lobbyId)).thenReturn(redTeam); + when(lobbyService.getPlayerRole(chatDto.senderUsername(), lobbyId)).thenReturn(Role.SPYMASTER); assertThrows( IllegalStateException.class, - () -> chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto, headerAccessor)); + () -> chatController.sendTeamOperativeMessage(lobbyId, blueTeam, chatDto)); } } diff --git a/src/test/java/com/codenames/codenames/backend/controller/HealthControllerTest.java b/src/test/java/com/codenames/codenames/backend/controller/HealthControllerTest.java new file mode 100644 index 00000000..b0d093c9 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/controller/HealthControllerTest.java @@ -0,0 +1,24 @@ +package com.codenames.codenames.backend.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(HealthController.class) +class HealthControllerTest { + + @Autowired private MockMvc mockMvc; + + @Test + void healthShouldReturnStatusUp() throws Exception { + mockMvc + .perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java new file mode 100644 index 00000000..44022629 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -0,0 +1,112 @@ +package com.codenames.codenames.backend.game.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codenames.codenames.backend.game.dto.ClueMessage; +import com.codenames.codenames.backend.game.dto.PassTurnMessage; +import com.codenames.codenames.backend.game.dto.RevealCardMessage; +import com.codenames.codenames.backend.game.dto.StartGameMessage; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Team; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +/** Tests websocket gameplay controller interactions. */ +@ExtendWith(MockitoExtension.class) +class GameSocketControllerTest { + + private static final String LOBBY_CODE = "ABCDE"; + + @Mock private GameService gameService; + + @Mock private SimpMessagingTemplate messagingTemplate; + + @Mock private SystemStatePersistenceService persistenceService; + + private GameSocketController controller; + + @BeforeEach + void setUp() { + controller = new GameSocketController(gameService, messagingTemplate, persistenceService); + } + + @Test + void startGameShouldBroadcastStateWithoutPersisting() { + StartGameMessage message = new StartGameMessage(); + message.setLobbyCode(LOBBY_CODE); + + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); + + controller.startGame(message); + + verify(persistenceService, never()).persistCurrentState(); + verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + } + + @Test + void revealCardShouldPersistAndBroadcastState() { + RevealCardMessage message = new RevealCardMessage(); + message.setLobbyCode(LOBBY_CODE); + message.setPosition(0); + message.setCurrentTurn(Team.RED); + + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); + + controller.revealCard(message); + + verify(gameService).flipCard(LOBBY_CODE, 0, Team.RED); + verify(persistenceService).persistCurrentState(); + verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + } + + @Test + void submitClueShouldPersistAndBroadcastState() { + ClueMessage message = new ClueMessage(); + message.setLobbyCode(LOBBY_CODE); + message.setWord("animal"); + message.setGuessAmount(2); + message.setCurrentTurn(Team.RED); + + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); + + controller.submitClue(message); + + verify(gameService).submitClue(anyString(), any(), any()); + verify(persistenceService).persistCurrentState(); + verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + } + + @Test + void passTurnShouldPersistAndBroadcastUpdatedState() { + PassTurnMessage message = new PassTurnMessage(); + message.setLobbyCode(LOBBY_CODE); + message.setCurrentTurn(Team.RED); + + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); + + controller.passTurn(message); + + verify(gameService).passTurn(LOBBY_CODE, Team.RED); + verify(persistenceService).persistCurrentState(); + verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + } + + private GameStateDataTransferObject createGameStateDataTransferObject() { + return new GameStateDataTransferObject(null, Team.RED, null, null, List.of()); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/lobby/LobbyTest.java b/src/test/java/com/codenames/codenames/backend/lobby/LobbyTest.java index e5f36d19..7c28cc89 100644 --- a/src/test/java/com/codenames/codenames/backend/lobby/LobbyTest.java +++ b/src/test/java/com/codenames/codenames/backend/lobby/LobbyTest.java @@ -23,7 +23,7 @@ void constructorShouldInitializeLobbyCorrectly() { assertEquals("ABCDE", lobby.getLobbyCode()); assertEquals(1, lobby.getPlayerList().size()); - assertTrue(lobby.getPlayerList().stream().anyMatch(p -> p.getUsername().equals("Host"))); + assertTrue(lobby.getPlayerList().stream().anyMatch(p -> p.username().equals("Host"))); } @Test @@ -33,7 +33,7 @@ void addPlayerShouldAddPlayer() { lobby.addPlayer("P1"); assertEquals(2, lobby.getPlayerList().size()); - assertTrue(lobby.getPlayerList().stream().anyMatch(p -> p.getUsername().equals("P1"))); + assertTrue(lobby.getPlayerList().stream().anyMatch(p -> p.username().equals("P1"))); } @Test @@ -55,7 +55,7 @@ void removePlayerShouldRemovePlayer() { lobby.addPlayer("P1"); lobby.removePlayer("P1"); - assertFalse(lobby.getPlayerList().stream().anyMatch(p -> p.getUsername().equals("P1"))); + assertFalse(lobby.getPlayerList().stream().anyMatch(p -> p.username().equals("P1"))); } @Test @@ -77,7 +77,7 @@ void addPlayerShouldNotAddDuplicatePlayer() { assertTrue(first); assertFalse(second); - long count = lobby.getPlayerList().stream().filter(p -> p.getUsername().equals("Max")).count(); + long count = lobby.getPlayerList().stream().filter(p -> p.username().equals("Max")).count(); assertEquals(1, count); } diff --git a/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java b/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java index 5c5971ce..3ae261c6 100644 --- a/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java @@ -1,13 +1,19 @@ package com.codenames.codenames.backend.lobby.controller; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +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 com.codenames.codenames.backend.lobby.dto.PlayerDto; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -18,74 +24,97 @@ @WebMvcTest(LobbyController.class) class LobbyControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; - @MockBean - private LobbyService service; + @MockBean private LobbyService service; + + @MockBean private SystemStatePersistenceService persistenceService; @Test void createLobbyShouldReturn200() throws Exception { when(service.createLobby("TestUser")).thenReturn("ABCDE"); - mockMvc.perform(post("/lobby/create") - .param("username", "TestUser")) + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + + verifyNoInteractions(persistenceService); } @Test void createLobbyBlankLobbyCode() throws Exception { when(service.createLobby("TestUser")).thenReturn(""); - mockMvc.perform(post("/lobby/create") - .param("username", "TestUser")) + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Error while creating lobby.")) + .andExpect(jsonPath("$.lobbyCode").value("")); + } + + @Test + void createLobbyNullLobbyCode() throws Exception { + when(service.createLobby("TestUser")).thenReturn(null); + + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.message").value("Error while creating lobby.")) .andExpect(jsonPath("$.lobbyCode").value("")); } @Test - void joinLobbyShouldReturn200_whenSuccess() throws Exception { + void joinLobbyShouldReturn200WhenSuccess() throws Exception { when(service.joinLobby("TestUser", "ABCDE")).thenReturn(true); - mockMvc.perform(post("/lobby/join") - .param("username", "TestUser") - .param("lobbyCode", "ABCDE")) + mockMvc + .perform(get("/lobby/ABCDE/join").param("username", "TestUser")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Joined Lobby successfully.")); + + verifyNoInteractions(persistenceService); + } + + @Test + void getLobbyInfoShouldReturn200() throws Exception { + when(service.getPlayersDto("ABCDE")) + .thenReturn(List.of(new PlayerDto("test", null, null, true))); + String url = "/lobby/ABCDE"; + mockMvc.perform(get(url)).andExpect(status().isOk()); } @Test - void joinLobbyShouldReturn400_whenNotFound() throws Exception { + void joinLobbyShouldReturn400WhenNotFound() throws Exception { when(service.joinLobby("TestUser", "XXXXX")).thenReturn(false); - mockMvc.perform(post("/lobby/join") - .param("username", "TestUser") - .param("lobbyCode", "XXXXX")) + mockMvc + .perform(get("/lobby/XXXXX/join").param("username", "TestUser")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("Could not find lobby.")); } @Test - void leaveLobbyShouldReturn200_whenSuccess() throws Exception { + void leaveLobbyShouldReturn200WhenSuccess() throws Exception { + when(service.getIsStarted("ABCDE")).thenReturn(false); when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(true); - mockMvc.perform(post("/lobby/leave") - .param("username", "TestUser") - .param("lobbyCode", "ABCDE")) + mockMvc + .perform(get("/lobby/ABCDE/leave").param("username", "TestUser")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Left lobby successfully.")); + + verifyNoInteractions(persistenceService); } @Test void leaveLobbyNoSuccess() throws Exception { when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(false); - mockMvc.perform(post("/lobby/leave") - .param("username", "TestUser") - .param("lobbyCode", "ABCDE")) + mockMvc + .perform( + get("/lobby/ABCDE/leave").param("username", "TestUser").param("lobbyCode", "ABCDE")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("Could not find lobby.")); } @@ -94,39 +123,97 @@ void leaveLobbyNoSuccess() throws Exception { void selectPositionShouldReturn200whenSuccess() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(true); - mockMvc.perform(post("/lobby/select-position") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "username": "TestUser", - "lobbyCode": "ABCDE", - "team": "RED", - "role": "SPYMASTER" - } - """)) + mockMvc + .perform( + post("/lobby/ABCDE/select-position") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "username": "TestUser", + "team": "RED", + "role": "SPYMASTER", + "isHost": "true" + } + """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Position selected successfully.")) .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + + verifyNoInteractions(persistenceService); } @Test - void selectPositionShouldReturn400whenAssignmentFails() throws Exception { + void selectPositionShouldReturn400WhenAssignmentFails() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(false); - mockMvc.perform(post("/lobby/select-position") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ - { - "username": "TestUser", - "lobbyCode": "ABCDE", - "team": "RED", - "role": "SPYMASTER" - } - """)) + mockMvc + .perform( + post("/lobby/ABCDE/select-position") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "username": "TestUser", + "team": "RED", + "role": "SPYMASTER", + "isHost": "true" + } + """)) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("Could not assign selected team/role.")) .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); } -} \ No newline at end of file + + @Test + void getLobbyInfoShouldReturn200WhenLobbyExists() throws Exception { + List players = + List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); + + when(service.getPlayersDto("ABCDE")).thenReturn(players); + + mockMvc + .perform(get("/lobby/ABCDE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Lobby info retrieved successfully.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) + .andExpect(jsonPath("$.playerList[0].username").value("Alice")) + .andExpect(jsonPath("$.playerList[1].username").value("Bob")); + } + + @Test + void testStartGameReturns200WhenConditionIsMet() throws Exception { + List players = + List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); + + when(service.getPlayersDto("ABCDE")).thenReturn(players); + when(service.startGame("ABCDE", "Alice")).thenReturn(true); + + mockMvc + .perform(get("/lobby/ABCDE/start-game").param("username", "Alice")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Game is starting now.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) + .andExpect(jsonPath("$.playerList[0].username").value("Alice")) + .andExpect(jsonPath("$.isStarted").value("true")); + + verify(persistenceService).persistCurrentState(); + } + + @Test + void testStartGameReturns400WhenServiceReturnsFalse() throws Exception { + List players = + List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); + + when(service.getPlayersDto("ABCDE")).thenReturn(players); + when(service.startGame("ABCDE", "Alice")).thenReturn(false); + + mockMvc + .perform(get("/lobby/ABCDE/start-game").param("username", "Alice")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not start the game.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) + .andExpect(jsonPath("$.playerList[0].username").value("Alice")) + .andExpect(jsonPath("$.isStarted").value("false")); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java b/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java index 43059b19..3363a43e 100644 --- a/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java @@ -5,31 +5,46 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.codenames.codenames.backend.chat.ChatService; +import com.codenames.codenames.backend.lobby.Lobby; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.playingfield.GameService; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import com.codenames.codenames.backend.websocket.Player; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests for {@link LobbyService}. * *

Validates lobby creation, joining, leaving, and player management behavior. */ - class LobbyServiceTest { private LobbyService lobbyService; private LobbyCodeGenerator generator; + private GameService gameService; @BeforeEach void setup() { generator = mock(LobbyCodeGenerator.class); - lobbyService = new LobbyService(generator); + gameService = mock(GameService.class); + ChatService chatService = mock(ChatService.class); + + lobbyService = new LobbyService(generator, chatService, gameService); when(generator.generateLobbyCode()).thenReturn("ABCDE"); } @@ -41,7 +56,7 @@ void createLobbyReturnLobbyCode() { assertTrue(result); List players = lobbyService.getPlayers("ABCDE"); - assertTrue(players.stream().anyMatch(p -> p.getUsername().equals("TestUser"))); + assertTrue(players.stream().anyMatch(p -> p.username().equals("TestUser"))); } @Test @@ -76,7 +91,7 @@ void leaveLobbyReturnTrueLobbyExists() { List players = lobbyService.getPlayers("ABCDE"); - assertFalse(players.stream().anyMatch(p -> p.getUsername().equals("Host"))); + assertFalse(players.stream().anyMatch(p -> p.username().equals("Host"))); } @Test @@ -87,10 +102,7 @@ void leaveLobbyReturnFalseLobbyNotExists() { @Test void createLobbyShouldGenerateNewCodeIfDuplicateExists() { - when(generator.generateLobbyCode()) - .thenReturn("ABCDE") - .thenReturn("ABCDE") - .thenReturn("FGHIJ"); + when(generator.generateLobbyCode()).thenReturn("ABCDE").thenReturn("ABCDE").thenReturn("FGHIJ"); lobbyService.createLobby("Host1"); String code2 = lobbyService.createLobby("Host2"); @@ -179,9 +191,7 @@ void joinLobbyShouldReturnFalseWhenPlayerAlreadyExists() { List players = lobbyService.getPlayers("ABCDE"); - long count = players.stream() - .filter(p -> p.getUsername().equals("Max")) - .count(); + long count = players.stream().filter(p -> p.username().equals("Max")).count(); assertEquals(1, count); } @@ -245,4 +255,139 @@ void getPlayerRole_nonExistentPlayer() { assertNull(lobbyService.getPlayerRole("nonExistentPlayer", lobbyCode)); } -} \ No newline at end of file + + @Test + void testLobbyIsRemovedWhenItIsEmpty() { + lobbyService.createLobby("Host"); + lobbyService.leaveLobby("Host", "ABCDE"); + lobbyService.checkLobbyStillHasPlayers("ABCDE"); + assertFalse(lobbyService.getLobbyList().containsKey("ABCDE")); + verify(gameService, times(1)).removeGame("ABCDE"); + } + + @Test + void testLobbyIsNotRemovedWhenItHasPlayers() { + lobbyService.createLobby("Host"); + lobbyService.joinLobby("Player1", "ABCDE"); + lobbyService.checkLobbyStillHasPlayers("ABCDE"); + assertTrue(lobbyService.getLobbyList().containsKey("ABCDE")); + } + + @Test + void testGetPlayersDto() { + lobbyService.createLobby("Host"); + + List players = lobbyService.getPlayersDto("ABCDE"); + PlayerDto player = players.get(0); + assertEquals("Host", player.username()); + assertTrue(player.isHost()); + } + + @Test + void testGetPlayersDto_lobbyNotExists() { + List players = lobbyService.getPlayersDto("UNKNOWN"); + assertNotNull(players); + assertTrue(players.isEmpty()); + } + + @Test + void getPlayersDtoShouldReturnPlayerDtosWhenLobbyExists() { + lobbyService.createLobby("Host"); + + List result = lobbyService.getPlayersDto("ABCDE"); + + assertNotNull(result); + assertEquals(1, result.size()); + + PlayerDto player = result.get(0); + + assertEquals("Host", player.username()); + assertNull(player.team()); + assertNull(player.role()); + assertTrue(player.isHost()); + } + + @Test + void getPlayersDtoShouldReturnEmptyList_whenLobbyDoesNotExist() { + List result = lobbyService.getPlayersDto("ABCDE"); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testAddGameManagerForLobby() { + lobbyService.createLobby("User"); + lobbyService.startGame("ABCDE", "User"); + verify(gameService, times(1)).createGameManager(eq("ABCDE"), any(Team.class)); + } + + @Test + void testGetIsStarted() { + when(gameService.isGameStarted("ABCDE")).thenReturn(true); + + boolean result = lobbyService.getIsStarted("ABCDE"); + assertTrue(result); + } + + @Test + void testGetIsStartedGameServiceReturnsFalse() { + when(gameService.isGameStarted("ABCDE")).thenReturn(false); + + boolean result = lobbyService.getIsStarted("ABCDE"); + assertFalse(result); + } + + @Test + void testGetHostWorks() { + lobbyService.createLobby("Alice"); + lobbyService.joinLobby("Bob", "ABCDE"); + lobbyService.joinLobby("Caesar", "ABCDE"); + + String expected = "Alice"; + String result = lobbyService.getHost("ABCDE"); + + assertEquals(expected, result); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {"ABCDE"}) + void testGetHostReturnsEmptyString(String lobbyCode) { + String result = lobbyService.getHost(lobbyCode); + + assertEquals("", result); + } + + @Test + void testRestoreLobbyAddsLobbyToLobbyList() { + Lobby restoredLobby = new Lobby("ABCDE", "Host"); + + lobbyService.restoreLobby("ABCDE", restoredLobby); + + assertTrue(lobbyService.getLobbyList().containsKey("ABCDE")); + assertEquals(restoredLobby, lobbyService.getLobbyList().get("ABCDE")); + } + + @Test + void getLobbySnapshotsShouldReturnAllLobbyPlayerDtos() { + lobbyService.createLobby("Host"); + lobbyService.joinLobby("Player", "ABCDE"); + lobbyService.selectPosition("Host", "ABCDE", Team.RED, Role.SPYMASTER); + + Map> snapshots = lobbyService.getLobbySnapshots(); + + assertTrue(snapshots.containsKey("ABCDE")); + assertEquals(2, snapshots.get("ABCDE").size()); + + PlayerDto host = + snapshots.get("ABCDE").stream() + .filter(player -> player.username().equals("Host")) + .findFirst() + .orElseThrow(); + + assertEquals(Team.RED, host.team()); + assertEquals(Role.SPYMASTER, host.role()); + assertTrue(host.isHost()); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java index 5e38e918..1f6308c1 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.when; import com.codenames.codenames.backend.utility.Color; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -28,8 +29,8 @@ void setUp() { Card card1 = new Card("Test1", Color.RED); Card card2 = new Card("Test2", Color.BLUE); - Card card3 = new Card("Test3", Color.WHITE); - Card card4 = new Card("Test4", Color.BLACK); + Card card3 = new Card("Test3", Color.NEUTRAL); + Card card4 = new Card("Test4", Color.ASSASSIN); dummyCardList = Arrays.asList(card1, card2, card3, card4); @@ -56,8 +57,8 @@ void testGetCardList() { void testCheckColor() { assertEquals(Color.RED, board.checkColor(0)); assertEquals(Color.BLUE, board.checkColor(1)); - assertEquals(Color.WHITE, board.checkColor(2)); - assertEquals(Color.BLACK, board.checkColor(3)); + assertEquals(Color.NEUTRAL, board.checkColor(2)); + assertEquals(Color.ASSASSIN, board.checkColor(3)); } @Test @@ -80,4 +81,13 @@ void testSetIsGuessed() { board.setGuessed(0); assertTrue(board.getIsGuessed(0)); } + + @Test + void testRecoveryConstructorCopiesInputList() { + List input = new ArrayList<>(dummyCardList); + Board recoveredBoard = new Board(input); + input.add(new Card("Extra", Color.RED)); + + assertEquals(4, recoveredBoard.getCardList().size()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/CardGeneratorTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/CardGeneratorTest.java index 58694d94..eed02baa 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/CardGeneratorTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/CardGeneratorTest.java @@ -30,8 +30,8 @@ void testGenerateCardsCorrectDistribution() { assertEquals(total, cards.size()); assertEquals(red, cards.stream().filter(card -> card.getColor() == Color.RED).count()); assertEquals(blue, cards.stream().filter(card -> card.getColor() == Color.BLUE).count()); - assertEquals(white, cards.stream().filter(card -> card.getColor() == Color.WHITE).count()); - assertEquals(black, cards.stream().filter(card -> card.getColor() == Color.BLACK).count()); + assertEquals(white, cards.stream().filter(card -> card.getColor() == Color.NEUTRAL).count()); + assertEquals(black, cards.stream().filter(card -> card.getColor() == Color.ASSASSIN).count()); } @Test diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/CardTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/CardTest.java index 0ab1da8a..8c9a0b0d 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/CardTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/CardTest.java @@ -21,7 +21,7 @@ void testConstructorHappyPath() { @Test void testConstructorNullWord() { - assertThrows(IllegalArgumentException.class, () -> new Card(null, Color.BLACK)); + assertThrows(IllegalArgumentException.class, () -> new Card(null, Color.ASSASSIN)); } @Test diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java new file mode 100644 index 00000000..25e72ad7 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java @@ -0,0 +1,113 @@ +package com.codenames.codenames.backend.playingfield; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GameManagerFactoryTest { + + private GameManagerFactory gameManagerFactory; + + @BeforeEach + void setup() { + CardGenerator mockCardGenerator = mock(CardGenerator.class); + ClueValidationService mockClueValidationService = mock(ClueValidationService.class); + + gameManagerFactory = new GameManagerFactory(mockCardGenerator, mockClueValidationService); + } + + @Test + void testCreate() { + GameManager gameManager = gameManagerFactory.create(Team.RED); + + assertNotNull(gameManager); + } + + @Test + void testCreateFromSnapshotWithClue() { + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + Team.RED, + Team.BLUE, + Role.OPERATIVE, + new ClueDto("ANIMAL", 2), + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertNotNull(recovered); + assertEquals(Team.BLUE, recovered.getCurrentTurn()); + assertEquals(Role.OPERATIVE, recovered.getCurrentPhase()); + assertEquals("ANIMAL", recovered.getCurrentClueWord()); + assertEquals(1, recovered.getCurrentRedFound()); + assertEquals(0, recovered.getCurrentBlueFound()); + assertEquals(Team.RED, recovered.getWinner()); + assertTrue(recovered.getCardList().get(0).isGuessed()); + } + + @Test + void testCreateFromSnapshotWithoutClue() { + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + null, + Team.BLUE, + Role.SPYMASTER, + null, + List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertNotNull(recovered); + assertEquals(Team.BLUE, recovered.getCurrentTurn()); + assertEquals(Role.SPYMASTER, recovered.getCurrentPhase()); + assertNull(recovered.getCurrentClue()); + } + + @Test + void testCreateFromSnapshotMapsCardsAndCountsGuessedCardsByColor() { + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + null, + Team.RED, + Role.OPERATIVE, + null, + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.RED, false), + new CardDataTransferObject("Tree", Color.BLUE, true), + new CardDataTransferObject("Sun", Color.NEUTRAL, true))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertEquals(4, recovered.getCardList().size()); + + assertEquals("Dog", recovered.getCardList().get(0).getWord()); + assertEquals(Color.RED, recovered.getCardList().get(0).getColor()); + assertTrue(recovered.getCardList().get(0).isGuessed()); + + assertEquals("Cat", recovered.getCardList().get(1).getWord()); + assertEquals(Color.RED, recovered.getCardList().get(1).getColor()); + + assertEquals("Tree", recovered.getCardList().get(2).getWord()); + assertEquals(Color.BLUE, recovered.getCardList().get(2).getColor()); + assertTrue(recovered.getCardList().get(2).isGuessed()); + + assertEquals(1, recovered.getCurrentRedFound()); + assertEquals(1, recovered.getCurrentBlueFound()); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java index 7ad244f5..615386a3 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -13,11 +14,14 @@ import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import java.util.ArrayList; import java.util.List; -import org.jspecify.annotations.NonNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -28,6 +32,10 @@ class GameManagerTest { private static final int SECOND_TEAM_CARDS = 8; private static final int WHITE_CARDS = 7; private static final int BLACK_CARDS = 1; + private static final Team redTeam = Team.RED; + private static final Team blueTeam = Team.BLUE; + private static final Color redColor = Color.RED; + private static final Color blueColor = Color.BLUE; private GameManager gameManager; private CardGenerator mockCardGenerator; private ClueValidationService mockClueValidationService; @@ -38,8 +46,8 @@ class GameManagerTest { void setUp() { mockCardGenerator = mock(CardGenerator.class); mockClueValidationService = mock(ClueValidationService.class); - mockCardGeneration(List.of(new Card("Test", Color.RED))); - gameManager = new GameManager(Team.RED, mockCardGenerator, mockClueValidationService); + mockCardGeneration(List.of(new Card("Test", redColor))); + gameManager = new GameManager(redTeam, mockCardGenerator, mockClueValidationService); when(mockClueValidationService.validateWord(any(), anyString())).thenReturn(true); } @@ -49,6 +57,29 @@ private void mockCardGeneration(List cardList) { .thenReturn(cardList); } + private void helperMethodSubmitClue(GameManager gameManager, int guessAmount, Team callingTeam) { + gameManager.submitClue(new Clue("Test", guessAmount), callingTeam); + } + + // Helper method for testing permutation of getWinner() + private GameManager helperMethodGenerateFullCardList(Color cardColor, Team startingTeam) { + List cardList = new ArrayList<>(); + for (int i = 0; i < TOTAL_CARDS; i++) { + cardList.add(new Card("Test" + i, cardColor)); + } + mockCardGeneration(cardList); + GameManager fullListGameManager = + new GameManager(startingTeam, mockCardGenerator, mockClueValidationService); + helperMethodSubmitClue(fullListGameManager, STARTING_TEAM_CARDS, startingTeam); + return fullListGameManager; + } + + private void helperMethodAdvanceTurns(GameManager gameManager, int advanceAmount) { + for (int i = 0; i < advanceAmount; i++) { + gameManager.advanceTurn(); + } + } + @Test void testConstructorRedStarts() { verify(mockCardGenerator, times(1)) @@ -58,7 +89,7 @@ void testConstructorRedStarts() { @Test void testConstructorBlueStarts() { - new GameManager(Team.BLUE, mockCardGenerator, mockClueValidationService); + new GameManager(blueTeam, mockCardGenerator, mockClueValidationService); verify(mockCardGenerator, times(1)) .generateCards( TOTAL_CARDS, SECOND_TEAM_CARDS, STARTING_TEAM_CARDS, WHITE_CARDS, BLACK_CARDS); @@ -78,7 +109,7 @@ void testGetCardList() { @Test void testCheckColor() { - assertEquals(Color.RED, gameManager.checkColor(0)); + assertEquals(redColor, gameManager.checkColor(0)); } @Test @@ -86,105 +117,96 @@ void testGetWinner_null() { assertNull(gameManager.getWinner()); } - private void helperMethodSubmitClue(GameManager gameManager, int guessAmount) { - gameManager.submitClue(new Clue("Test", guessAmount)); - } - - // Helper method for testing permutation of getWinner() - private @NonNull GameManager helperMethodGenerateFullCardList( - Color cardColor, Team startingTeam) { - List cardList = new ArrayList<>(); - for (int i = 0; i < 25; i++) { - cardList.add(new Card("Test" + i, cardColor)); - } - mockCardGeneration(cardList); - GameManager fullListGameManager = - new GameManager(startingTeam, mockCardGenerator, mockClueValidationService); - helperMethodSubmitClue(fullListGameManager, 9); - return fullListGameManager; - } - @Test void testGetWinner_redStartsRedWins() { - gameManager = helperMethodGenerateFullCardList(Color.RED, Team.RED); + gameManager = helperMethodGenerateFullCardList(redColor, redTeam); - for (int i = 0; i < 9; i++) { - gameManager.flipCard(i, Color.RED); + for (int i = 0; i < STARTING_TEAM_CARDS; i++) { + gameManager.flipCard(i, redTeam); } - assertEquals(Color.RED, gameManager.getWinner()); + assertEquals(redTeam, gameManager.getWinner()); } @Test void testGetWinner_redStartsBlueWins() { - gameManager = helperMethodGenerateFullCardList(Color.BLUE, Team.RED); + gameManager = helperMethodGenerateFullCardList(blueColor, redTeam); + + helperMethodAdvanceTurns(gameManager, 1); // blue spymaster + helperMethodSubmitClue(gameManager, SECOND_TEAM_CARDS, blueTeam); - for (int i = 0; i < 8; i++) { - gameManager.flipCard(i, Color.BLUE); + for (int i = 0; i < SECOND_TEAM_CARDS; i++) { + gameManager.flipCard(i, blueTeam); } - assertEquals(Color.BLUE, gameManager.getWinner()); + assertEquals(blueTeam, gameManager.getWinner()); } @Test void testGetWinner_blueStartsRedWins() { - gameManager = helperMethodGenerateFullCardList(Color.RED, Team.BLUE); + gameManager = helperMethodGenerateFullCardList(redColor, blueTeam); + helperMethodAdvanceTurns(gameManager, 1); // red spymaster + helperMethodSubmitClue(gameManager, 8, redTeam); - for (int i = 0; i < 8; i++) { - gameManager.flipCard(i, Color.RED); + for (int i = 0; i < SECOND_TEAM_CARDS; i++) { + gameManager.flipCard(i, redTeam); } - assertEquals(Color.RED, gameManager.getWinner()); + assertEquals(redTeam, gameManager.getWinner()); } @Test void testGetWinner_blueStartsBlueWins() { - gameManager = helperMethodGenerateFullCardList(Color.BLUE, Team.BLUE); + gameManager = helperMethodGenerateFullCardList(blueColor, blueTeam); - for (int i = 0; i < 9; i++) { - gameManager.flipCard(i, Color.BLUE); + for (int i = 0; i < STARTING_TEAM_CARDS; i++) { + gameManager.flipCard(i, blueTeam); } - assertEquals(Color.BLUE, gameManager.getWinner()); + assertEquals(blueTeam, gameManager.getWinner()); } @Test void testGetWinner_redFoundBlackCardFound() { - mockCardGeneration(List.of(new Card("Test", Color.BLACK))); - gameManager = new GameManager(Team.RED, mockCardGenerator, mockClueValidationService); - helperMethodSubmitClue(gameManager, 1); - gameManager.flipCard(0, Color.RED); - assertEquals(Color.BLUE, gameManager.getWinner()); + mockCardGeneration(List.of(new Card("Test", Color.ASSASSIN))); + gameManager = new GameManager(blueTeam, mockCardGenerator, mockClueValidationService); + helperMethodSubmitClue(gameManager, 1, blueTeam); + helperMethodAdvanceTurns(gameManager, 1); // red spymaster + helperMethodSubmitClue(gameManager, 1, redTeam); + + gameManager.flipCard(0, redTeam); + assertEquals(blueTeam, gameManager.getWinner()); } @Test void testGetWinner_blueFoundBlackCardFound() { - mockCardGeneration(List.of(new Card("Test", Color.BLACK))); - gameManager = new GameManager(Team.RED, mockCardGenerator, mockClueValidationService); - helperMethodSubmitClue(gameManager, 1); - gameManager.flipCard(0, Color.BLUE); - assertEquals(Color.RED, gameManager.getWinner()); + mockCardGeneration(List.of(new Card("Test", Color.ASSASSIN))); + gameManager = new GameManager(redTeam, mockCardGenerator, mockClueValidationService); + helperMethodAdvanceTurns(gameManager, 2); // blue spymaster + helperMethodSubmitClue(gameManager, 1, blueTeam); + gameManager.flipCard(0, blueTeam); + assertEquals(redTeam, gameManager.getWinner()); } @Test void testFlipWhiteCard() { - mockCardGeneration(List.of(new Card("Test", Color.WHITE))); - gameManager = new GameManager(Team.RED, mockCardGenerator, mockClueValidationService); - helperMethodSubmitClue(gameManager, 1); - gameManager.flipCard(0, Color.BLUE); + mockCardGeneration(List.of(new Card("Test", Color.NEUTRAL))); + gameManager = new GameManager(redTeam, mockCardGenerator, mockClueValidationService); + helperMethodSubmitClue(gameManager, 1, redTeam); + gameManager.flipCard(0, redTeam); assertNull(gameManager.getWinner()); } @Test void testFlipCard_cardAlreadyFlipped() { - helperMethodSubmitClue(gameManager, 1); - gameManager.flipCard(0, Color.RED); - assertThrows(IllegalStateException.class, () -> gameManager.flipCard(0, Color.RED)); + helperMethodSubmitClue(gameManager, 1, redTeam); + gameManager.flipCard(0, redTeam); + assertThrows(IllegalStateException.class, () -> gameManager.flipCard(0, redTeam)); } @Test void testFlipCard_winnerAlreadyDetermined() { - mockCardGeneration(List.of(new Card("Test", Color.BLACK))); - gameManager = new GameManager(Team.RED, mockCardGenerator, mockClueValidationService); - helperMethodSubmitClue(gameManager, 1); - gameManager.flipCard(0, Color.BLUE); - assertThrows(IllegalStateException.class, () -> gameManager.flipCard(0, Color.RED)); + mockCardGeneration(List.of(new Card("Test", Color.ASSASSIN))); + gameManager = new GameManager(blueTeam, mockCardGenerator, mockClueValidationService); + helperMethodSubmitClue(gameManager, 1, blueTeam); + gameManager.flipCard(0, blueTeam); + assertThrows(IllegalStateException.class, () -> gameManager.flipCard(0, blueTeam)); } @Test @@ -201,42 +223,177 @@ void testGetCurrentBlueFoundCards() { @Test void testSubmitClue() { - Clue validClue = new Clue("Test", 2); - gameManager.submitClue(validClue); + Clue validClue = new Clue("Test", 1); + gameManager.submitClue(validClue, redTeam); assertEquals(validClue, gameManager.getCurrentClue()); - assertEquals(3, gameManager.getRemainingGuesses()); } @Test void testOutOfGuesses() { - mockCardGeneration(List.of(new Card("Test", Color.RED), new Card("Test2", Color.RED))); - gameManager = new GameManager(Team.RED, mockCardGenerator, mockClueValidationService); - helperMethodSubmitClue(gameManager, 0); - gameManager.flipCard(0, Color.RED); - assertThrows(IllegalStateException.class, () -> gameManager.flipCard(1, Color.RED)); + mockCardGeneration(List.of(new Card("Test", redColor), new Card("Test2", redColor))); + gameManager = new GameManager(redTeam, mockCardGenerator, mockClueValidationService); + helperMethodSubmitClue(gameManager, 0, redTeam); + gameManager.flipCard(0, redTeam); + assertThrows(IllegalStateException.class, () -> gameManager.flipCard(1, redTeam)); } @Test void testGetCurrentClueWord() { - helperMethodSubmitClue(gameManager, 1); + helperMethodSubmitClue(gameManager, 1, redTeam); assertEquals("Test", gameManager.getCurrentClueWord()); } @Test void testGetRemainingGuesses() { - helperMethodSubmitClue(gameManager, 1); - assertEquals(2, gameManager.getRemainingGuesses()); + int guessAmount = 2; // According to game rules, players have guessAmount + 1 + helperMethodSubmitClue(gameManager, guessAmount, redTeam); + assertEquals(guessAmount + 1, gameManager.getRemainingGuesses()); } @Test void testSubmitClue_invalidClue() { when(mockClueValidationService.validateWord(any(), anyString())).thenReturn(false); Clue invalidClue = new Clue("InvalidClue", 1); - assertThrows(IllegalArgumentException.class, () -> gameManager.submitClue((invalidClue))); + assertThrows( + IllegalArgumentException.class, () -> gameManager.submitClue((invalidClue), redTeam)); } @Test void testGetCurrentClueWordNullUponInitialization() { assertNull(gameManager.getCurrentClueWord()); } + + @Test + void testCorrectStart_redTeam() { + assertEquals(redTeam, gameManager.getCurrentTurn()); + } + + @Test + void testCorrectStart_spymaster() { + assertEquals(Role.SPYMASTER, gameManager.getCurrentPhase()); + } + + @Test + void testAdvanceTurn_spymasterToOperative() { + helperMethodAdvanceTurns(gameManager, 1); + assertEquals(Role.OPERATIVE, gameManager.getCurrentPhase()); + } + + @Test + void testAdvanceTurn_spymasterToOperative_sameTeam() { + helperMethodAdvanceTurns(gameManager, 1); + assertEquals(redTeam, gameManager.getCurrentTurn()); + } + + @Test + void testAdvanceTurnTwice_operativeToSpymaster() { + helperMethodAdvanceTurns(gameManager, 2); + assertEquals(Role.SPYMASTER, gameManager.getCurrentPhase()); + } + + @Test + void testAdvanceTurnTwice_redTeamToBlueTeam() { + helperMethodAdvanceTurns(gameManager, 2); + assertEquals(blueTeam, gameManager.getCurrentTurn()); + } + + @Test + void testAdvanceTurnTwice_wipeClue() { + helperMethodAdvanceTurns(gameManager, 2); + assertNull(gameManager.getCurrentClue()); + } + + @Test + void testPassTurn_correctTeam() { + helperMethodAdvanceTurns(gameManager, 1); + gameManager.passTurn(redTeam); + assertEquals(blueTeam, gameManager.getCurrentTurn()); + } + + @Test + void testPassTurn_correctPhase() { + helperMethodAdvanceTurns(gameManager, 1); + gameManager.passTurn(redTeam); + assertEquals(Role.SPYMASTER, gameManager.getCurrentPhase()); + } + + @Test + void testCheckCorrectTurn_throwsWhenWrongRole() { + assertThrows(IllegalStateException.class, () -> gameManager.flipCard(0, redTeam)); + } + + @Test + void testCheckCorrectTurn_throwsWhenWrongTeam() { + Clue clue = new Clue("Test", 1); + assertThrows(IllegalStateException.class, () -> gameManager.submitClue(clue, blueTeam)); + } + + @Test + void testPassTurn_throwsWhenSpymaster() { + assertThrows(IllegalStateException.class, () -> gameManager.passTurn(redTeam)); + } + + @Test + void recoveryConstructorThrowsWhenCardsAreNull() { + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, Role.SPYMASTER, null, null); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCardsAreEmpty() { + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, Role.SPYMASTER, null, List.of()); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCurrentTurnIsNull() { + List cards = + List.of(new CardDataTransferObject("Dog", Color.RED, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, null, Role.SPYMASTER, null, cards); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { + List cards = + List.of(new CardDataTransferObject("Dog", Color.RED, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, null, null, cards); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorRestoresPersistedState() { + List cards = + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject( + Team.RED, Team.BLUE, Role.OPERATIVE, new ClueDto("ANIMAL", 2), cards); + + GameManager restored = new GameManager(state, mockClueValidationService); + + assertEquals(Team.BLUE, restored.getCurrentTurn()); + assertEquals(Role.OPERATIVE, restored.getCurrentPhase()); + assertEquals("ANIMAL", restored.getCurrentClueWord()); + assertEquals(Team.RED, restored.getWinner()); + assertEquals(1, restored.getCurrentRedFound()); + assertEquals(0, restored.getCurrentBlueFound()); + assertTrue(restored.getCardList().get(0).isGuessed()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java new file mode 100644 index 00000000..c8b01b29 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java @@ -0,0 +1,157 @@ +package com.codenames.codenames.backend.playingfield; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codenames.codenames.backend.clue.Clue; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.DataTransferObjectService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Tests the functionality of GameService. */ +class GameServiceTest { + private GameService gameService; + private GameManager mockGameManager; + private GameManagerFactory mockGameManagerFactory; + private DataTransferObjectService mockDtoService; + + private final String lobbyCode = "ABCDE"; + private final Team redTeam = Team.RED; + + @BeforeEach + void setup() { + mockGameManagerFactory = mock(GameManagerFactory.class); + mockGameManager = mock(GameManager.class); + mockDtoService = mock(DataTransferObjectService.class); + + gameService = new GameService(mockGameManagerFactory, mockDtoService); + when(mockGameManagerFactory.create(redTeam)).thenReturn(mockGameManager); + + gameService.createGameManager(lobbyCode, redTeam); + } + + @Test + void testCreateGameManager_oneInvocation() { + verify(mockGameManagerFactory, times(1)).create(redTeam); + } + + @Test + void testCreateGameManager_twoInvocations_noDuplicates() { + gameService.createGameManager(lobbyCode, redTeam); + gameService.createGameManager(lobbyCode, redTeam); + verify(mockGameManagerFactory, times(1)).create(redTeam); + } + + @Test + void testRemoveGame() { + gameService.removeGame(lobbyCode); + + assertThrows(IllegalStateException.class, () -> gameService.flipCard(lobbyCode, 0, redTeam)); + } + + @Test + void testSubmitClue() { + Clue mockClue = new Clue("ANIMAL", 2); + + gameService.submitClue(lobbyCode, mockClue, redTeam); + verify(mockGameManager, times(1)).submitClue(mockClue, redTeam); + } + + @Test + void testFlipCard() { + int firstCard = 0; + gameService.flipCard(lobbyCode, firstCard, redTeam); + + verify(mockGameManager, times(1)).flipCard(firstCard, redTeam); + } + + @Test + void testPassTurn() { + gameService.passTurn(lobbyCode, redTeam); + + verify(mockGameManager, times(1)).passTurn(redTeam); + } + + @Test + void testGetGameState() { + + GameManager result = gameService.getGameState(lobbyCode); + + assertEquals(mockGameManager, result); + } + + @Test + void testRestoreGameManager() { + String restoredLobbyCode = "VWXYZ"; + GameManager restoredGameManager = mock(GameManager.class); + + gameService.restoreGameManager(restoredLobbyCode, restoredGameManager); + + assertEquals(restoredGameManager, gameService.getGameState(restoredLobbyCode)); + } + + @Test + void testGetCurrentGameState() { + GameStateDataTransferObject expected = + new GameStateDataTransferObject( + null, + redTeam, + Role.SPYMASTER, + new ClueDto("ANIMAL", 2), + List.of(new CardDataTransferObject("Dog", Color.RED, false))); + when(mockGameManager.getCurrentTurn()).thenReturn(redTeam); + when(mockGameManager.getCurrentPhase()).thenReturn(Role.SPYMASTER); + when(mockGameManager.getRemainingGuesses()).thenReturn(2); + when(mockDtoService.createGameStateDataTransferObject(mockGameManager, redTeam, Role.SPYMASTER)) + .thenReturn(expected); + + GameStateDataTransferObject result = gameService.getCurrentGameState(lobbyCode); + + assertEquals(expected, result); + } + + @Test + void testIsGameStartedWhenGameExists() { + assertTrue(gameService.isGameStarted(lobbyCode)); + } + + @Test + void testIsGameStartedWhenGameDoesNotExist() { + assertFalse(gameService.isGameStarted("UNKNOWN")); + } + + @Test + void getGameSnapshotsShouldReturnAllCurrentGameStates() { + GameStateDataTransferObject expected = + new GameStateDataTransferObject( + null, + redTeam, + Role.SPYMASTER, + new ClueDto("ANIMAL", 2), + List.of(new CardDataTransferObject("Dog", Color.RED, false))); + + when(mockGameManager.getCurrentTurn()).thenReturn(redTeam); + when(mockGameManager.getCurrentPhase()).thenReturn(Role.SPYMASTER); + when(mockDtoService.createGameStateDataTransferObject(mockGameManager, redTeam, Role.SPYMASTER)) + .thenReturn(expected); + + Map snapshots = gameService.getGameSnapshots(); + + assertEquals(1, snapshots.size()); + assertEquals(expected, snapshots.get(lobbyCode)); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java new file mode 100644 index 00000000..b03acf68 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -0,0 +1,173 @@ +package com.codenames.codenames.backend.recovery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JsonStateStoreTest { + + @TempDir Path tempDir; + + @Test + void loadReturnsEmptyWhenStateFileDoesNotExist() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isEmpty()); + } + + @Test + void loadReturnsEmptyWhenStateFileIsEmpty() throws IOException { + Path stateFile = tempDir.resolve("state.json"); + Files.createFile(stateFile); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isEmpty()); + } + + @Test + void saveAndLoadRoundTrip() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + List lobbyPlayers = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); + + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, Team.RED, Role.OPERATIVE, new ClueDto("ANIMAL", 2), List.of()); + + SystemSnapshot expectedSnapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", lobbyPlayers), + Map.of("ABCDE", gameSnapshot)); + + stateStore.save(expectedSnapshot); + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isPresent()); + assertTrue(Files.exists(stateFile)); + + SystemSnapshot actualSnapshot = loadedSnapshot.get(); + assertEquals(SystemSnapshot.CURRENT_SCHEMA_VERSION, actualSnapshot.schemaVersion()); + assertEquals(1, actualSnapshot.lobbies().size()); + assertEquals(1, actualSnapshot.games().size()); + + List actualPlayers = actualSnapshot.lobbies().get("ABCDE"); + assertEquals(2, actualPlayers.size()); + assertEquals("Host", actualPlayers.get(0).username()); + assertEquals(Team.RED, actualPlayers.get(0).team()); + assertEquals(Role.SPYMASTER, actualPlayers.get(0).role()); + assertTrue(actualPlayers.get(0).isHost()); + + GameStateDataTransferObject actualGameSnapshot = actualSnapshot.games().get("ABCDE"); + assertEquals(Team.RED, actualGameSnapshot.currentTurn()); + assertEquals(Role.OPERATIVE, actualGameSnapshot.currentPhase()); + assertEquals("ANIMAL", actualGameSnapshot.currentClue().word()); + assertEquals(2, actualGameSnapshot.currentClue().guessAmount()); + assertTrue(actualGameSnapshot.cardList().isEmpty()); + } + + @Test + void saveOverwritesPreviousSnapshot() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + SystemSnapshot firstSnapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + SystemSnapshot secondSnapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", List.of()), + Map.of()); + + stateStore.save(firstSnapshot); + stateStore.save(secondSnapshot); + + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isPresent()); + assertTrue(loadedSnapshot.get().lobbies().containsKey("ABCDE")); + assertEquals(1, loadedSnapshot.get().lobbies().size()); + } + + @Test + void saveThrowsIllegalStateWhenStateParentCannotBeCreated() throws IOException { + Path fileAsParent = tempDir.resolve("not-a-directory"); + Files.writeString(fileAsParent, "occupied"); + Path stateFile = fileAsParent.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + + assertThrows(IllegalStateException.class, () -> stateStore.save(snapshot)); + } + + @Test + void loadThrowsIllegalStateWhenJsonIsInvalid() throws IOException { + Path stateFile = tempDir.resolve("state.json"); + Files.writeString(stateFile, "{invalid-json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + assertThrows(IllegalStateException.class, stateStore::load); + } + + @Test + void gettersExposeConfiguredDependencies() { + ObjectMapper mapper = new ObjectMapper(); + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(mapper, stateFile.toString()); + + assertSame(mapper, stateStore.getObjectMapper()); + assertEquals(stateFile, stateStore.getStateFilePath()); + assertNotNull(stateStore.getIoLock()); + } + + @Test + void saveWorksWhenStateFileHasNoParentDirectory() throws IOException { + String fileName = "json-state-store-" + System.nanoTime() + ".json"; + Path stateFile = Path.of(fileName); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), fileName); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + + try { + stateStore.save(snapshot); + + assertTrue(Files.exists(stateFile)); + assertTrue(stateStore.load().isPresent()); + } finally { + Files.deleteIfExists(stateFile); + Files.deleteIfExists(Path.of(fileName + ".tmp")); + } + } + + private Optional loadSnapshot(JsonStateStore stateStore) { + return stateStore.load(); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java new file mode 100644 index 00000000..c0d956dc --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java @@ -0,0 +1,49 @@ +package com.codenames.codenames.backend.recovery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class SystemStatePersistenceServiceTest { + + @Test + void persistCurrentStateSavesCurrentSystemSnapshot() { + JsonStateStore stateStore = mock(JsonStateStore.class); + LobbyService lobbyService = mock(LobbyService.class); + GameService gameService = mock(GameService.class); + + Map> lobbySnapshots = Map.of("ABCDE", List.of()); + + Map gameSnapshots = Map.of(); + + when(lobbyService.getLobbySnapshots()).thenReturn(lobbySnapshots); + when(gameService.getGameSnapshots()).thenReturn(gameSnapshots); + + SystemStatePersistenceService persistenceService = + new SystemStatePersistenceService(stateStore, lobbyService, gameService); + + persistenceService.persistCurrentState(); + + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(SystemSnapshot.class); + + verify(stateStore, times(1)).save(snapshotCaptor.capture()); + + SystemSnapshot snapshot = snapshotCaptor.getValue(); + + assertEquals(SystemSnapshot.CURRENT_SCHEMA_VERSION, snapshot.schemaVersion()); + assertEquals(lobbySnapshots, snapshot.lobbies()); + assertEquals(gameSnapshots, snapshot.games()); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java new file mode 100644 index 00000000..a0d7528d --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java @@ -0,0 +1,262 @@ +package com.codenames.codenames.backend.recovery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codenames.codenames.backend.chat.ChatService; +import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.lobby.Lobby; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.lobby.services.LobbyCodeGenerator; +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.CardGenerator; +import com.codenames.codenames.backend.playingfield.GameManager; +import com.codenames.codenames.backend.playingfield.GameManagerFactory; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.DataTransferObjectService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SystemStateRecoveryServiceTest { + + @TempDir Path tempDir; + + @Test + void recoverOnStartupDoesNothingWhenNoSnapshotExists() { + TestContext context = createContext(tempDir.resolve("state.json")); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupSkipsSnapshotWhenSchemaVersionDiffers() { + TestContext context = createContext(tempDir.resolve("state.json")); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION + 1, Map.of(), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupRestoresLobbiesAndGamesFromSnapshot() { + TestContext context = createContext(tempDir.resolve("state.json")); + List lobbyPlayers = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, + Team.RED, + Role.OPERATIVE, + new ClueDto("ANIMAL", 2), + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false))); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", lobbyPlayers), + Map.of("ABCDE", gameSnapshot)); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + Lobby restoredLobby = context.lobbyService().getLobbyList().get("ABCDE"); + assertEquals(2, restoredLobby.getPlayerList().size()); + assertEquals(Team.RED, restoredLobby.getPlayerTeam("Host")); + assertEquals(Role.SPYMASTER, restoredLobby.getPlayerRole("Host")); + assertEquals(Team.BLUE, restoredLobby.getPlayerTeam("Player")); + assertEquals(Role.OPERATIVE, restoredLobby.getPlayerRole("Player")); + + assertTrue(context.gameService().isGameStarted("ABCDE")); + GameManager restoredGame = context.gameService().getGameState("ABCDE"); + assertEquals(Team.RED, restoredGame.getCurrentTurn()); + assertEquals(Role.OPERATIVE, restoredGame.getCurrentPhase()); + assertEquals("ANIMAL", restoredGame.getCurrentClue().word()); + assertTrue(restoredGame.getCardList().get(0).isGuessed()); + assertFalse(restoredGame.getCardList().get(1).isGuessed()); + } + + @Test + void recoverOnStartupWithCurrentSchemaAndEmptyMapsDoesNothing() { + TestContext context = createContext(tempDir.resolve("state-empty.json")); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupHandlesNullLobbyAndGameMaps() { + TestContext context = createContext(tempDir.resolve("state-null-maps.json")); + SystemSnapshot snapshot = new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, null, null); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupSkipsLobbyWhenSnapshotEntryIsNull() { + TestContext context = createContext(tempDir.resolve("state-null-lobby-entry.json")); + Map> lobbies = new HashMap<>(); + lobbies.put("ABCDE", null); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, lobbies, Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupSkipsLobbyWhenPlayersListIsNull() { + TestContext context = createContext(tempDir.resolve("state-null-players.json")); + Map> lobbies = new HashMap<>(); + lobbies.put("ABCDE", null); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, lobbies, Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupSkipsLobbyWhenPlayersListIsEmpty() { + TestContext context = createContext(tempDir.resolve("state-empty-players.json")); + List lobbySnapshot = List.of(); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupSkipsLobbyWhenAllUsernamesAreInvalid() { + TestContext context = createContext(tempDir.resolve("state-invalid-usernames.json")); + List lobbySnapshot = + List.of( + new PlayerDto(" ", Team.RED, Role.SPYMASTER, true), + new PlayerDto(null, Team.BLUE, Role.OPERATIVE, false)); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupRestoresLobbyWhenSomeTeamOrRoleValuesAreMissing() { + TestContext context = createContext(tempDir.resolve("state-missing-team-role.json")); + List lobbySnapshot = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", null, null, false)); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + Lobby restoredLobby = context.lobbyService().getLobbyList().get("ABCDE"); + assertEquals(Team.RED, restoredLobby.getPlayerTeam("Host")); + assertEquals(Role.SPYMASTER, restoredLobby.getPlayerRole("Host")); + assertNull(restoredLobby.getPlayerTeam("Player")); + assertNull(restoredLobby.getPlayerRole("Player")); + } + + @Test + void recoverOnStartupRestoresOnlyGamesWhenLobbiesMapIsNull() { + TestContext context = createContext(tempDir.resolve("state-lobbies-null-games-present.json")); + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, + Team.BLUE, + Role.SPYMASTER, + null, + List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, null, Map.of("ABCDE", gameSnapshot)); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertTrue(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupRestoresOnlyLobbiesWhenGamesMapIsNull() { + TestContext context = createContext(tempDir.resolve("state-games-null-lobbies-present.json")); + List lobbySnapshot = List.of(new PlayerDto("Host", Team.RED, Role.SPYMASTER, true)); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), null); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().containsKey("ABCDE")); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + private TestContext createContext(Path stateFile) { + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + GameManagerFactory gameManagerFactory = + new GameManagerFactory( + new CardGenerator("CodenamesWordlist.txt"), new ClueValidationService()); + GameService gameService = new GameService(gameManagerFactory, new DataTransferObjectService()); + LobbyService lobbyService = + new LobbyService(new LobbyCodeGenerator(), new ChatService(null), gameService); + SystemStateRecoveryService recoveryService = + new SystemStateRecoveryService(stateStore, lobbyService, gameService, gameManagerFactory); + + return new TestContext(stateStore, lobbyService, gameService, recoveryService); + } + + private record TestContext( + JsonStateStore stateStore, + LobbyService lobbyService, + GameService gameService, + SystemStateRecoveryService recoveryService) {} +} diff --git a/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java b/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java index 389988a8..12050802 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java @@ -5,10 +5,12 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.playingfield.Card; import com.codenames.codenames.backend.playingfield.GameManager; import com.codenames.codenames.backend.utility.Color; import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -19,6 +21,9 @@ class DataTransferObjectServiceTest { GameManager mockGameManager; DataTransferObjectService service; GameStateDataTransferObject gameStateDto; + private static final Team redTeam = Team.RED; + private static final Role spymaster = Role.SPYMASTER; + private static final Role operative = Role.OPERATIVE; @BeforeEach void setUp() { @@ -29,44 +34,61 @@ void setUp() { mockGameManager = mock(GameManager.class); service = new DataTransferObjectService(); when(mockGameManager.getCardList()).thenReturn(List.of(cardHidden, cardGuessed)); - when(mockGameManager.getWinner()).thenReturn(Color.RED); + when(mockGameManager.getWinner()).thenReturn(Team.RED); when(mockGameManager.getCurrentRedFound()).thenReturn(0); when(mockGameManager.getCurrentBlueFound()).thenReturn(0); + when(mockGameManager.getRemainingGuesses()).thenReturn(2); + + gameStateDto = + service.createGameStateDataTransferObject(mockGameManager, redTeam, spymaster); } @Test void testSpymasterVisibility() { gameStateDto = - service.createGameStateDataTransferObject(mockGameManager, Role.SPYMASTER, "RED"); - assertEquals("RED", gameStateDto.cardList().get(0).color()); + service.createGameStateDataTransferObject(mockGameManager, redTeam, spymaster); + assertEquals(Color.RED, gameStateDto.cardList().get(0).color()); } @Test void testOperatorVisibility_hidden() { - gameStateDto = - service.createGameStateDataTransferObject(mockGameManager, Role.OPERATIVE, "RED"); - assertEquals("HIDDEN", gameStateDto.cardList().get(0).color()); + assertEquals(Color.RED, gameStateDto.cardList().get(0).color()); } @Test void testOperatorVisibility_isGuessed() { - gameStateDto = - service.createGameStateDataTransferObject(mockGameManager, Role.OPERATIVE, "RED"); - assertEquals("RED", gameStateDto.cardList().get(1).color()); + assertEquals(Color.RED, gameStateDto.cardList().get(1).color()); } @Test void testGetWinner_exists() { - gameStateDto = - service.createGameStateDataTransferObject(mockGameManager, Role.OPERATIVE, "RED"); - assertEquals("RED", gameStateDto.winner()); + assertEquals(redTeam, gameStateDto.winner()); } @Test void testGetWinner_null() { when(mockGameManager.getWinner()).thenReturn(null); gameStateDto = - service.createGameStateDataTransferObject(mockGameManager, Role.OPERATIVE, "RED"); + service.createGameStateDataTransferObject(mockGameManager, redTeam, operative); assertNull(gameStateDto.winner()); } + + @Test + void testCreateGameStateDataTransferObject() { + Clue clue = new Clue("word", 1); + when(mockGameManager.getCardList()).thenReturn(List.of()); + when(mockGameManager.getWinner()).thenReturn(null); + when(mockGameManager.getCurrentClue()).thenReturn(clue); + when(mockGameManager.getRemainingGuesses()).thenReturn(3); + + GameStateDataTransferObject dto = + service.createGameStateDataTransferObject(mockGameManager, redTeam, operative); + + assertEquals(clue.word(), dto.currentClue().word()); + assertEquals(clue.guessAmount(), dto.currentClue().guessAmount()); + assertNull(dto.winner()); + assertEquals(0, dto.cardList().size()); + assertEquals(redTeam, dto.currentTurn()); + assertEquals(operative, dto.currentPhase()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java b/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java index 4d096039..e2b109c1 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java @@ -6,8 +6,11 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.codenames.codenames.backend.game.dto.ClueDto; import com.codenames.codenames.backend.playingfield.Card; import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; @@ -21,22 +24,25 @@ class SerializationJsonTest { List dummyList; GameStateDataTransferObject dummyGameState; ObjectMapper mapper = new ObjectMapper(); + private static final Team redTeam = Team.RED; + private static final Role spymaster = Role.SPYMASTER; @BeforeEach void setUp() { card = new Card("TEST", Color.RED); serializer = new SerializationJson(mapper); - dummyList = List.of(new CardDataTransferObject("TEST", "HIDDEN", false)); - dummyGameState = new GameStateDataTransferObject("RED", "RED", 0, 0, "Test", 1, dummyList); + dummyList = List.of(new CardDataTransferObject("TEST", null, false)); + dummyGameState = + new GameStateDataTransferObject( + redTeam, redTeam, spymaster, new ClueDto("Test", 1), dummyList); } @Test void testSerialize_pass() { String expectedResult = - "{\"winner\":\"RED\",\"currentTurn\":\"RED\",\"currentRedFound\":0,\"currentBlueFound\":0" - + ",\"currentClue\":\"Test\",\"remainingGuesses\":1,\"cardList\":[{\"word\":\"TEST\"," - + "\"color\":\"HIDDEN\",\"isGuessed\":false}]}"; + """ + {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}"""; String result = serializer.serialize(dummyGameState); assertEquals(expectedResult, result); } diff --git a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java index 3818cfe2..815bde5d 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -10,6 +10,8 @@ import static org.mockito.Mockito.when; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +26,7 @@ class GameControllerTest { private LobbyService lobbyService; + private GameService gameService; private SessionRegistry sessionRegistry; private GameController controller; private SimpMessagingTemplate messagingTemplate; @@ -31,10 +34,11 @@ class GameControllerTest { @BeforeEach void setup() { lobbyService = mock(LobbyService.class); + gameService = mock(GameService.class); messagingTemplate = mock(SimpMessagingTemplate.class); sessionRegistry = new SessionRegistry(); - controller = new GameController(lobbyService, messagingTemplate, sessionRegistry); + controller = new GameController(lobbyService, gameService, messagingTemplate, sessionRegistry); } @Test @@ -53,7 +57,8 @@ void shouldRegisterJoinAndRegisterSession() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); - when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max"))); + when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -63,6 +68,7 @@ void shouldRegisterJoinAndRegisterSession() { assertEquals("ABCDE", sessionRegistry.getLobby("123")); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } @Test @@ -79,11 +85,11 @@ void shouldSendErrorMessageWhenJoinFails() { accessor.setSessionAttributes(attrs); when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(false); + when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of()); controller.join(msg, accessor); verify(messagingTemplate).convertAndSend("/topic/errors/123", "Join failed"); - verifyNoMoreInteractions(messagingTemplate); } @@ -116,7 +122,8 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { accessor.setSessionAttributes(attrs); when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); - when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max"))); + when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -125,5 +132,36 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { verify(lobbyService).joinLobby("Max", "ABCDE"); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); + } + + @Test + void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { + + JoinMessage msg = new JoinMessage(); + msg.setName("Max"); + msg.setCode("ABCDE"); + + SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create(); + + java.util.Map attrs = new java.util.HashMap<>(); + attrs.put("sessionId", "reconnect-1"); + accessor.setSessionAttributes(attrs); + + when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(false); + when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); + + controller.join(msg, accessor); + + assertEquals("Max", sessionRegistry.getUser("reconnect-1")); + assertEquals("ABCDE", sessionRegistry.getLobby("reconnect-1")); + + verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); + verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); + } + + private GameStateDataTransferObject createGameStatePayload() { + return new GameStateDataTransferObject(null, null, null, null, List.of()); } } diff --git a/src/test/java/com/codenames/codenames/backend/websocket/PlayerTest.java b/src/test/java/com/codenames/codenames/backend/websocket/PlayerTest.java index 1fad9b66..33f1b002 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/PlayerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/PlayerTest.java @@ -8,8 +8,8 @@ class PlayerTest { @Test void shouldReturnUsername() { - Player player = new Player("Max"); + Player player = new Player("Max", false); - assertEquals("Max", player.getUsername()); + assertEquals("Max", player.username()); } } diff --git a/src/test/java/com/codenames/codenames/backend/websocket/WebSocketEventListenerTest.java b/src/test/java/com/codenames/codenames/backend/websocket/WebSocketEventListenerTest.java index fcb45290..88577291 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/WebSocketEventListenerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/WebSocketEventListenerTest.java @@ -1,84 +1,98 @@ package com.codenames.codenames.backend.websocket; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import com.codenames.codenames.backend.lobby.services.LobbyService; +import java.lang.reflect.Field; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.socket.messaging.SessionDisconnectEvent; /** Unit tests for {@link WebSocketEventListener}. */ class WebSocketEventListenerTest { + private static final String TEST_SESSION_ID = "123"; private SessionRegistry registry; - private LobbyService lobbyService; - private SimpMessagingTemplate messagingTemplate; private WebSocketEventListener listener; @BeforeEach void setup() { registry = new SessionRegistry(); - lobbyService = mock(LobbyService.class); - messagingTemplate = mock(SimpMessagingTemplate.class); - - listener = new WebSocketEventListener(registry, lobbyService, messagingTemplate); + listener = new WebSocketEventListener(registry); } @Test - void shouldHandleDisconnectAndRemovePlayer() { - - registry.register("123", "Max", "ABCDE"); + void shouldHandleDisconnectAndRemoveSessionMapping() { - SessionDisconnectEvent event = mock(SessionDisconnectEvent.class); - when(event.getSessionId()).thenReturn("123"); + registry.register(TEST_SESSION_ID, "Max", "ABCDE"); - when(lobbyService.getPlayers("ABCDE")).thenReturn(java.util.List.of()); + SessionDisconnectEvent event = org.mockito.Mockito.mock(SessionDisconnectEvent.class); + when(event.getSessionId()).thenReturn(TEST_SESSION_ID); listener.handleDisconnect(event); - verify(lobbyService).leaveLobby("Max", "ABCDE"); - assertNull(registry.getUser("123")); + assertNull(registry.getUser(TEST_SESSION_ID)); + assertNull(registry.getLobby(TEST_SESSION_ID)); } @Test void shouldIgnoreDisconnectWhenUsernameIsNull() { - SessionDisconnectEvent event = mock(SessionDisconnectEvent.class); - when(event.getSessionId()).thenReturn("123"); + SessionDisconnectEvent event = org.mockito.Mockito.mock(SessionDisconnectEvent.class); + when(event.getSessionId()).thenReturn(TEST_SESSION_ID); listener.handleDisconnect(event); - verifyNoInteractions(lobbyService); - verifyNoInteractions(messagingTemplate); + assertNull(registry.getUser(TEST_SESSION_ID)); } @Test void shouldIgnoreDisconnectWhenLobbyIsMissing() { - registry.register("123", "Max", "ABCDE"); - registry.remove("123"); + registry.register(TEST_SESSION_ID, "Max", "ABCDE"); + registry.remove(TEST_SESSION_ID); - SessionDisconnectEvent event = mock(SessionDisconnectEvent.class); - when(event.getSessionId()).thenReturn("123"); + SessionDisconnectEvent event = org.mockito.Mockito.mock(SessionDisconnectEvent.class); + when(event.getSessionId()).thenReturn(TEST_SESSION_ID); listener.handleDisconnect(event); - - verifyNoInteractions(lobbyService); - verifyNoInteractions(messagingTemplate); } @Test void shouldIgnoreUnknownSession() { - SessionDisconnectEvent event = mock(SessionDisconnectEvent.class); + SessionDisconnectEvent event = org.mockito.Mockito.mock(SessionDisconnectEvent.class); when(event.getSessionId()).thenReturn("unknown"); listener.handleDisconnect(event); - verifyNoInteractions(lobbyService); + assertNull(registry.getUser(TEST_SESSION_ID)); + } + + @Test + void shouldIgnoreDisconnectWhenLobbyIsNullButUserExists() throws Exception { + + registry.register(TEST_SESSION_ID, "Max", "ABCDE"); + removeLobbyMappingForTestSession(); + + SessionDisconnectEvent event = org.mockito.Mockito.mock(SessionDisconnectEvent.class); + when(event.getSessionId()).thenReturn(TEST_SESSION_ID); + + listener.handleDisconnect(event); + + assertEquals("Max", registry.getUser(TEST_SESSION_ID)); + assertNull(registry.getLobby(TEST_SESSION_ID)); + } + + @SuppressWarnings("unchecked") + private void removeLobbyMappingForTestSession() throws Exception { + Field lobbyField = SessionRegistry.class.getDeclaredField("sessionToLobby"); + lobbyField.setAccessible(true); + + Map sessionToLobby = (Map) lobbyField.get(registry); + sessionToLobby.remove(TEST_SESSION_ID); + + assertNull(registry.getLobby(TEST_SESSION_ID)); } } diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..fdbd0b15 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass