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