From 2bde83a0e3d6642edc13db02f7fa2bb56bbef4ba Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 17:40:26 +0200 Subject: [PATCH 01/60] feat: add system snapshot model for restart recovery --- .../backend/recovery/snapshot/ClueSnapshot.java | 3 +++ .../backend/recovery/snapshot/GameSnapshot.java | 16 ++++++++++++++++ .../backend/recovery/snapshot/LobbySnapshot.java | 6 ++++++ .../recovery/snapshot/SystemSnapshot.java | 9 +++++++++ 4 files changed, 34 insertions(+) create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java new file mode 100644 index 0000000..e4b304e --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java @@ -0,0 +1,3 @@ +package com.codenames.codenames.backend.recovery.snapshot; + +public record ClueSnapshot(String word, int guessAmount) {} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java new file mode 100644 index 0000000..f988370 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java @@ -0,0 +1,16 @@ +package com.codenames.codenames.backend.recovery.snapshot; + +import com.codenames.codenames.backend.playingfield.Card; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import java.util.List; + +public record GameSnapshot( + Team currentTurn, + Role currentPhase, + Team winner, + int currentRedFound, + int currentBlueFound, + int remainingGuesses, + ClueSnapshot currentClue, + List cards) {} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java new file mode 100644 index 0000000..77f8470 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java @@ -0,0 +1,6 @@ +package com.codenames.codenames.backend.recovery.snapshot; + +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import java.util.List; + +public record LobbySnapshot(String lobbyCode, List players) {} 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 0000000..d8c4630 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java @@ -0,0 +1,9 @@ +package com.codenames.codenames.backend.recovery.snapshot; + +import java.util.Map; + +public record SystemSnapshot( + int schemaVersion, Map lobbies, Map games) { + + public static final int CURRENT_SCHEMA_VERSION = 1; +} From 4f6ec34eb63e2f36fca6e048f61270a4962c7f8e Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 18:35:01 +0200 Subject: [PATCH 02/60] feat: add json state store for atomic snapshot persistence --- .../backend/recovery/JsonStateStore.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java 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 0000000..def3976 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java @@ -0,0 +1,71 @@ +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; + +@Component +@Getter +public class JsonStateStore { + + private final ObjectMapper objectMapper; + private final Path stateFilePath; + private final ReentrantLock ioLock = new ReentrantLock(); + + public JsonStateStore( + ObjectMapper objectMapper, @Value("${app.state-file:data/state.json}") String stateFile) { + this.objectMapper = objectMapper; + this.stateFilePath = Path.of(stateFile); + } + + 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(); + } + } + + 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(); + } + } + + 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); + } + } +} From a888582fcde6f444b4103eef631793749b005b2e Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 18:37:29 +0200 Subject: [PATCH 03/60] docs: add javadocs --- .../backend/recovery/JsonStateStore.java | 28 +++++++++++++++++++ .../recovery/snapshot/ClueSnapshot.java | 6 ++++ .../recovery/snapshot/GameSnapshot.java | 12 ++++++++ .../recovery/snapshot/LobbySnapshot.java | 6 ++++ .../recovery/snapshot/SystemSnapshot.java | 7 +++++ 5 files changed, 59 insertions(+) diff --git a/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java index def3976..1c58c2c 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java @@ -13,6 +13,11 @@ 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 { @@ -21,12 +26,23 @@ public class JsonStateStore { 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 { @@ -46,6 +62,11 @@ public void save(SystemSnapshot snapshot) { } } + /** + * Loads the persisted system snapshot when present. + * + * @return optional snapshot + */ public Optional load() { ioLock.lock(); try { @@ -60,6 +81,13 @@ public Optional load() { } } + /** + * 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( diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java index e4b304e..2e3b2fb 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java @@ -1,3 +1,9 @@ package com.codenames.codenames.backend.recovery.snapshot; +/** + * Persisted clue payload used for restart recovery snapshots. + * + * @param word clue word + * @param guessAmount clue guess amount + */ public record ClueSnapshot(String word, int guessAmount) {} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java index f988370..779088e 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java @@ -5,6 +5,18 @@ import com.codenames.codenames.backend.utility.Team; import java.util.List; +/** + * Persisted game payload used for restart recovery snapshots. + * + * @param currentTurn current team turn + * @param currentPhase current gameplay phase + * @param winner winner if already decided + * @param currentRedFound discovered red cards + * @param currentBlueFound discovered blue cards + * @param remainingGuesses remaining guesses + * @param currentClue current clue if present + * @param cards full card state list + */ public record GameSnapshot( Team currentTurn, Role currentPhase, diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java index 77f8470..992b00d 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java @@ -3,4 +3,10 @@ import com.codenames.codenames.backend.lobby.dto.PlayerDto; import java.util.List; +/** + * Persisted lobby payload used for restart recovery snapshots. + * + * @param lobbyCode lobby identifier + * @param players players including team/role/host metadata + */ public record LobbySnapshot(String lobbyCode, List players) {} 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 index d8c4630..cdee2c2 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java @@ -2,6 +2,13 @@ import java.util.Map; +/** + * Root snapshot aggregate for persisted backend runtime state. + * + * @param schemaVersion persisted schema version + * @param lobbies lobby snapshots keyed by lobby code + * @param games game snapshots keyed by lobby code + */ public record SystemSnapshot( int schemaVersion, Map lobbies, Map games) { From 11c84b1d0f76819ceceb42a9e993d281b91af229 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 18:41:34 +0200 Subject: [PATCH 04/60] test: add unit tests for json state store load and save behavior --- .../backend/recovery/JsonStateStoreTest.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java 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 0000000..086c538 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -0,0 +1,121 @@ +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.assertTrue; + +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.recovery.snapshot.ClueSnapshot; +import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; +import com.codenames.codenames.backend.recovery.snapshot.LobbySnapshot; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +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 = stateStore.load(); + + 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 = stateStore.load(); + + assertTrue(loadedSnapshot.isEmpty()); + } + + @Test + void saveAndLoadRoundTrip() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + LobbySnapshot lobbySnapshot = + new LobbySnapshot( + "ABCDE", + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false))); + + GameSnapshot gameSnapshot = + new GameSnapshot( + Team.RED, Role.OPERATIVE, null, 1, 0, 2, new ClueSnapshot("ANIMAL", 2), List.of()); + + SystemSnapshot expectedSnapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", lobbySnapshot), + Map.of("ABCDE", gameSnapshot)); + + stateStore.save(expectedSnapshot); + Optional loadedSnapshot = stateStore.load(); + + 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()); + + LobbySnapshot actualLobbySnapshot = actualSnapshot.lobbies().get("ABCDE"); + assertEquals("ABCDE", actualLobbySnapshot.lobbyCode()); + assertEquals(2, actualLobbySnapshot.players().size()); + assertEquals("Host", actualLobbySnapshot.players().get(0).username()); + assertEquals(Team.RED, actualLobbySnapshot.players().get(0).team()); + assertEquals(Role.SPYMASTER, actualLobbySnapshot.players().get(0).role()); + assertTrue(actualLobbySnapshot.players().get(0).isHost()); + + GameSnapshot actualGameSnapshot = actualSnapshot.games().get("ABCDE"); + assertEquals(Team.RED, actualGameSnapshot.currentTurn()); + assertEquals(Role.OPERATIVE, actualGameSnapshot.currentPhase()); + assertEquals(2, actualGameSnapshot.remainingGuesses()); + assertEquals("ANIMAL", actualGameSnapshot.currentClue().word()); + assertEquals(2, actualGameSnapshot.currentClue().guessAmount()); + assertTrue(actualGameSnapshot.cards().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", new LobbySnapshot("ABCDE", List.of())), + Map.of()); + + stateStore.save(firstSnapshot); + stateStore.save(secondSnapshot); + + Optional loadedSnapshot = stateStore.load(); + + assertTrue(loadedSnapshot.isPresent()); + assertTrue(loadedSnapshot.get().lobbies().containsKey("ABCDE")); + assertFalse(loadedSnapshot.get().lobbies().isEmpty()); + } +} From 55bae8f735c2c1379fbc0e7d31a6b149c0216b2a Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 18:44:14 +0200 Subject: [PATCH 05/60] test: replace redundant lobby emptiness assertion with size check --- .../codenames/backend/recovery/JsonStateStoreTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index 086c538..887a38b 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -116,6 +116,6 @@ void saveOverwritesPreviousSnapshot() { assertTrue(loadedSnapshot.isPresent()); assertTrue(loadedSnapshot.get().lobbies().containsKey("ABCDE")); - assertFalse(loadedSnapshot.get().lobbies().isEmpty()); + assertEquals(1, loadedSnapshot.get().lobbies().size()); } } From aa5e9903b90cb30822dc81c28752bc9918205174 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 22:08:26 +0200 Subject: [PATCH 06/60] test: add json state store exception path coverage --- .../backend/recovery/JsonStateStoreTest.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index 887a38b..0ef8363 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -1,7 +1,7 @@ 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.codenames.codenames.backend.lobby.dto.PlayerDto; @@ -118,4 +118,25 @@ void saveOverwritesPreviousSnapshot() { 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); + } } From ef7145afef9d5f98623c8adab67290fe77f08e84 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 22:10:19 +0200 Subject: [PATCH 07/60] refactor: extract snapshot load helper in json state store test --- .../backend/recovery/JsonStateStoreTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index 0ef8363..3156c38 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -30,7 +30,7 @@ void loadReturnsEmptyWhenStateFileDoesNotExist() { Path stateFile = tempDir.resolve("state.json"); JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); - Optional loadedSnapshot = stateStore.load(); + Optional loadedSnapshot = loadSnapshot(stateStore); assertTrue(loadedSnapshot.isEmpty()); } @@ -41,7 +41,7 @@ void loadReturnsEmptyWhenStateFileIsEmpty() throws IOException { Files.createFile(stateFile); JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); - Optional loadedSnapshot = stateStore.load(); + Optional loadedSnapshot = loadSnapshot(stateStore); assertTrue(loadedSnapshot.isEmpty()); } @@ -69,7 +69,7 @@ void saveAndLoadRoundTrip() { Map.of("ABCDE", gameSnapshot)); stateStore.save(expectedSnapshot); - Optional loadedSnapshot = stateStore.load(); + Optional loadedSnapshot = loadSnapshot(stateStore); assertTrue(loadedSnapshot.isPresent()); assertTrue(Files.exists(stateFile)); @@ -112,7 +112,7 @@ void saveOverwritesPreviousSnapshot() { stateStore.save(firstSnapshot); stateStore.save(secondSnapshot); - Optional loadedSnapshot = stateStore.load(); + Optional loadedSnapshot = loadSnapshot(stateStore); assertTrue(loadedSnapshot.isPresent()); assertTrue(loadedSnapshot.get().lobbies().containsKey("ABCDE")); @@ -139,4 +139,8 @@ void loadThrowsIllegalStateWhenJsonIsInvalid() throws IOException { assertThrows(IllegalStateException.class, stateStore::load); } + + private Optional loadSnapshot(JsonStateStore stateStore) { + return stateStore.load(); + } } From cb9d408312243ba1821163387a7159a2cd27c6d3 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 22:21:19 +0200 Subject: [PATCH 08/60] test: improve JsonStateStore coverage for getters and parentless save path --- .../backend/recovery/JsonStateStoreTest.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index 3156c38..4c3081a 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -1,6 +1,7 @@ package com.codenames.codenames.backend.recovery; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -140,6 +141,36 @@ void loadThrowsIllegalStateWhenJsonIsInvalid() throws IOException { 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()); + assertSame(stateStore.getIoLock(), 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(); } From cc55c714fc30ba2fe440926ea618dcce2b474585 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 22:23:32 +0200 Subject: [PATCH 09/60] test: replace self-assertion on ioLock getter with non-null assertion --- .../codenames/backend/recovery/JsonStateStoreTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index 4c3081a..8f1afa6 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -1,6 +1,7 @@ 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; @@ -149,7 +150,7 @@ void gettersExposeConfiguredDependencies() { assertSame(mapper, stateStore.getObjectMapper()); assertEquals(stateFile, stateStore.getStateFilePath()); - assertSame(stateStore.getIoLock(), stateStore.getIoLock()); + assertNotNull(stateStore.getIoLock()); } @Test From 36fa601473c4f57000b76a70d5ae61b82d93ad04 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 23:04:43 +0200 Subject: [PATCH 10/60] feat: add startup state recovery service --- .../recovery/SystemStateRecoveryService.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java 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 0000000..e7a3104 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java @@ -0,0 +1,111 @@ +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.GameSnapshot; +import com.codenames.codenames.backend.recovery.snapshot.LobbySnapshot; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SystemStateRecoveryService { + + private final JsonStateStore stateStore; + private final LobbyService lobbyService; + private final GameService gameService; + private final GameManagerFactory gameManagerFactory; + + public SystemStateRecoveryService( + JsonStateStore stateStore, + LobbyService lobbyService, + GameService gameService, + GameManagerFactory gameManagerFactory) { + this.stateStore = stateStore; + this.lobbyService = lobbyService; + this.gameService = gameService; + this.gameManagerFactory = gameManagerFactory; + } + + @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()); + }); + } + + 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); + } + } + } + + 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); + } + } + + private Lobby buildLobby(String lobbyCode, LobbySnapshot snapshot) { + if (snapshot == null || snapshot.players() == null || snapshot.players().isEmpty()) { + log.warn("Skipping restore for lobby {} due to missing player data.", lobbyCode); + return null; + } + + List players = + snapshot.players().stream() + .filter(player -> player.username() != null && !player.username().isBlank()) + .sorted(Comparator.comparing(PlayerDto::isHost).reversed()) + .toList(); + + if (players.isEmpty()) { + return null; + } + + PlayerDto host = players.get(0); + Lobby lobby = new Lobby(lobbyCode, host.username()); + + for (PlayerDto player : players) { + 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; + } +} From 7dc1752d130fc5d1bb45b67c160742c57a6346dc Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 23:07:54 +0200 Subject: [PATCH 11/60] feat: add lobby and game restore hooks --- .../codenames/backend/lobby/services/LobbyService.java | 4 ++++ .../codenames/codenames/backend/playingfield/GameService.java | 4 ++++ 2 files changed, 8 insertions(+) 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 42c88f1..efe032c 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 @@ -90,6 +90,10 @@ public boolean joinLobby(String username, String lobbyCode) { return false; } + public void restoreLobby(String lobbyCode, Lobby lobby) { + lobbyList.put(lobbyCode, lobby); + } + /** * Removes a player from a lobby. * diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java index d750c38..c5af45f 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -51,6 +51,10 @@ public void removeGame(String lobbyCode) { games.remove(lobbyCode); } + public void restoreGameManager(String lobbyCode, GameManager gameManager) { + games.put(lobbyCode, gameManager); + } + /** * Helper method to retrieve a GM object from the hash map. * From 87708a4811abfc040cd4cd28e97ef594c1c7c4fc Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 23:15:52 +0200 Subject: [PATCH 12/60] feat: add GameManager and Board constructors for snapshot restore --- .../codenames/backend/playingfield/Board.java | 5 +++ .../backend/playingfield/GameManager.java | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+) 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 ad38df1..6580bdc 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,10 @@ public Board( this.cardList = cardGenerator.generateCards(totalWords, red, blue, white, black); } + 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/GameManager.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java index 6a35b5f..26ad5a8 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java @@ -64,6 +64,41 @@ public GameManager( new Board(cardGenerator, TOTAL_CARDS, redCards, blueCards, WHITE_CARDS, BLACK_CARDS); } + public GameManager( + List cards, + Team currentTurn, + Role currentPhase, + Team winner, + int currentRedFound, + int currentBlueFound, + int remainingGuesses, + Clue currentClue, + ClueValidationService clueValidationService) { + if (cards == null || cards.isEmpty()) { + throw new IllegalArgumentException("cards cannot be null or empty"); + } + if (currentTurn == null || currentPhase == null) { + throw new IllegalArgumentException("current turn and phase cannot be null"); + } + + this.currentTurn = currentTurn; + this.currentPhase = currentPhase; + this.winner = winner; + this.currentRedFound = currentRedFound; + this.currentBlueFound = currentBlueFound; + this.remainingGuesses = remainingGuesses; + this.currentClue = currentClue; + this.clueValidationService = clueValidationService; + + this.redCards = countCardsByColor(cards, Color.RED); + this.blueCards = countCardsByColor(cards, Color.BLUE); + this.board = new Board(cards); + } + + 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. * From 7a170d916ce5dc1c4d6ae096be8fd8f4de3ec2b7 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 23:16:49 +0200 Subject: [PATCH 13/60] refactor: use CardDataTransferObject in game snapshots for recovery --- .../playingfield/GameManagerFactory.java | 31 +++++++++++++++++++ .../recovery/snapshot/GameSnapshot.java | 4 +-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java index 37e1032..d1608e2 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -1,7 +1,11 @@ package com.codenames.codenames.backend.playingfield; +import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.utility.Team; +import java.util.List; import org.springframework.stereotype.Component; /** Generates GameManager instances to be used by GameService. */ @@ -31,4 +35,31 @@ public GameManagerFactory( public GameManager create(Team startingTeam) { return new GameManager(startingTeam, cardGenerator, clueValidationService); } + + public GameManager createFromSnapshot(GameSnapshot snapshot) { + Clue clue = + snapshot.currentClue() == null + ? null + : new Clue(snapshot.currentClue().word(), snapshot.currentClue().guessAmount()); + List cards = snapshot.cards().stream().map(this::toCard).toList(); + + return new GameManager( + cards, + snapshot.currentTurn(), + snapshot.currentPhase(), + snapshot.winner(), + snapshot.currentRedFound(), + snapshot.currentBlueFound(), + snapshot.remainingGuesses(), + clue, + clueValidationService); + } + + private Card toCard(CardDataTransferObject cardDto) { + Card card = new Card(cardDto.word(), cardDto.color()); + if (cardDto.isGuessed()) { + card.setIsGuessedTrue(); + } + return card; + } } diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java index 779088e..871263f 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java @@ -1,6 +1,6 @@ package com.codenames.codenames.backend.recovery.snapshot; -import com.codenames.codenames.backend.playingfield.Card; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import java.util.List; @@ -25,4 +25,4 @@ public record GameSnapshot( int currentBlueFound, int remainingGuesses, ClueSnapshot currentClue, - List cards) {} + List cards) {} From 078d507ed982103e7c1d75e62592d6a04c437466 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Mon, 18 May 2026 23:25:06 +0200 Subject: [PATCH 14/60] test: add startup recovery tests for SystemStateRecoveryService --- .../SystemStateRecoveryServiceTest.java | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java 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 0000000..9fe7f1c --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java @@ -0,0 +1,125 @@ +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.assertTrue; + +import com.codenames.codenames.backend.chat.ChatService; +import com.codenames.codenames.backend.clue.ClueValidationService; +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.ClueSnapshot; +import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; +import com.codenames.codenames.backend.recovery.snapshot.LobbySnapshot; +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.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.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")); + LobbySnapshot lobbySnapshot = + new LobbySnapshot( + "ABCDE", + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false))); + GameSnapshot gameSnapshot = + new GameSnapshot( + Team.RED, + Role.OPERATIVE, + null, + 1, + 0, + 2, + new ClueSnapshot("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", lobbySnapshot), + 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(2, restoredGame.getRemainingGuesses()); + assertEquals("ANIMAL", restoredGame.getCurrentClue().word()); + assertTrue(restoredGame.getCardList().get(0).isGuessed()); + assertFalse(restoredGame.getCardList().get(1).isGuessed()); + } + + 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) {} +} From 9b99653d86cdde4316f2a92a591104afab483909 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 00:16:32 +0200 Subject: [PATCH 15/60] test: maximize SystemStateRecoveryService branch coverage --- .../SystemStateRecoveryServiceTest.java | 155 +++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java index 9fe7f1c..87f59ee 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java @@ -2,6 +2,7 @@ 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; @@ -25,6 +26,7 @@ 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; @@ -104,10 +106,161 @@ void recoverOnStartupRestoresLobbiesAndGamesFromSnapshot() { 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")); + LobbySnapshot lobbySnapshot = new LobbySnapshot("ABCDE", null); + 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 recoverOnStartupSkipsLobbyWhenPlayersListIsEmpty() { + TestContext context = createContext(tempDir.resolve("state-empty-players.json")); + LobbySnapshot lobbySnapshot = new LobbySnapshot("ABCDE", 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")); + LobbySnapshot lobbySnapshot = + new LobbySnapshot( + "ABCDE", + 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")); + LobbySnapshot lobbySnapshot = + new LobbySnapshot( + "ABCDE", + 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")); + GameSnapshot gameSnapshot = + new GameSnapshot( + Team.BLUE, + Role.SPYMASTER, + null, + 0, + 0, + 0, + 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")); + LobbySnapshot lobbySnapshot = + new LobbySnapshot("ABCDE", 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()); + 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); From 9d654bd205862a89a117b61ff639afc0bd54bd3c Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 00:59:00 +0200 Subject: [PATCH 16/60] test: add tests for recovery --- .../backend/lobby/services/LobbyService.java | 17 ++-- .../backend/playingfield/GameService.java | 7 +- .../lobby/services/LobbyServiceTest.java | 12 +++ .../backend/playingfield/BoardTest.java | 10 ++ .../playingfield/GameManagerFactoryTest.java | 51 ++++++++++ .../backend/playingfield/GameManagerTest.java | 97 +++++++++++++++++++ .../backend/playingfield/GameServiceTest.java | 53 +++++++++- 7 files changed, 229 insertions(+), 18 deletions(-) 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 efe032c..9106cb0 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 @@ -25,8 +25,7 @@ @Service public class LobbyService { - @Getter - private final Map lobbyList = new ConcurrentHashMap<>(); + @Getter private final Map lobbyList = new ConcurrentHashMap<>(); private final LobbyCodeGenerator generator; private final GameService gameService; private final ChatService chatService; @@ -260,17 +259,16 @@ public Role getPlayerRole(String username, String lobbyCode) { } /** - * 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. + * 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); + boolean isStarted = + !lobbyCode.isBlank() && !username.isBlank() && Objects.equals(getHost(lobbyCode), username); Lobby lobby = lobbyList.get(lobbyCode); addGameManagerForLobby(lobby, lobbyCode); @@ -284,7 +282,6 @@ public boolean startGame(String lobbyCode, String username) { * @param lobbyCode the unique lobby code * @return the username of the host */ - public String getHost(String lobbyCode) { if (lobbyCode == null || lobbyCode.isBlank()) { return ""; @@ -302,13 +299,11 @@ public String getHost(String lobbyCode) { } /** - * Checks if the game is started by looking after an existing - * game manager object. + * 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); } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java index c5af45f..59f8d48 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -141,13 +141,12 @@ public GameStateDto createGameStateDto(String lobbyCode) { public GameStateDataTransferObject getCurrentGameState(String lobbyCode) { GameManager gm = getGame(lobbyCode); return dtoService.createGameStateDataTransferObject( - gm, gm.getCurrentTurn(), gm.getCurrentPhase() - ); + 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. + * 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 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 ede33cd..46ad282 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 @@ -16,6 +16,7 @@ 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; @@ -260,6 +261,7 @@ void testLobbyIsRemovedWhenItIsEmpty() { lobbyService.leaveLobby("Host", "ABCDE"); lobbyService.checkLobbyStillHasPlayers("ABCDE"); assertFalse(lobbyService.getLobbyList().containsKey("ABCDE")); + verify(gameService, times(1)).removeGame("ABCDE"); } @Test @@ -355,4 +357,14 @@ void testGetHost_ReturnsEmptyString(String 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")); + } } 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 bfde018..1f6308c 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; @@ -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/GameManagerFactoryTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java index 4857495..983433a 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java @@ -1,10 +1,19 @@ 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.recovery.snapshot.ClueSnapshot; +import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +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; @@ -26,4 +35,46 @@ void testCreate() { assertNotNull(gameManager); } + + @Test + void testCreateFromSnapshotWithClue() { + GameSnapshot snapshot = + new GameSnapshot( + Team.RED, + Role.OPERATIVE, + null, + 1, + 0, + 2, + new ClueSnapshot("ANIMAL", 2), + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertNotNull(recovered); + assertTrue(recovered.getCardList().get(0).isGuessed()); + assertEquals(2, recovered.getRemainingGuesses()); + assertEquals("ANIMAL", recovered.getCurrentClueWord()); + } + + @Test + void testCreateFromSnapshotWithoutClue() { + GameSnapshot snapshot = + new GameSnapshot( + Team.BLUE, + Role.SPYMASTER, + null, + 0, + 0, + 0, + null, + List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertNotNull(recovered); + assertNull(recovered.getCurrentClue()); + } } 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 5397a1d..dcfa984 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; @@ -328,4 +329,100 @@ void testCheckCorrectTurn_throwsWhenWrongTeam() { void testPassTurn_throwsWhenSpymaster() { assertThrows(IllegalStateException.class, () -> gameManager.passTurn(redTeam)); } + + @Test + void recoveryConstructorThrowsWhenCardsAreNull() { + assertThrows( + IllegalArgumentException.class, + () -> + new GameManager( + null, + Team.RED, + Role.SPYMASTER, + null, + 0, + 0, + 0, + null, + mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCardsAreEmpty() { + assertThrows( + IllegalArgumentException.class, + () -> + new GameManager( + List.of(), + Team.RED, + Role.SPYMASTER, + null, + 0, + 0, + 0, + null, + mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCurrentTurnIsNull() { + assertThrows( + IllegalArgumentException.class, + () -> + new GameManager( + List.of(new Card("Dog", Color.RED)), + null, + Role.SPYMASTER, + null, + 0, + 0, + 0, + null, + mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { + assertThrows( + IllegalArgumentException.class, + () -> + new GameManager( + List.of(new Card("Dog", Color.RED)), + Team.RED, + null, + null, + 0, + 0, + 0, + null, + mockClueValidationService)); + } + + @Test + void recoveryConstructorRestoresPersistedState() { + Card guessedRed = new Card("Dog", Color.RED); + guessedRed.setIsGuessedTrue(); + List cards = new ArrayList<>(); + cards.add(guessedRed); + cards.add(new Card("Cat", Color.BLUE)); + + GameManager restored = + new GameManager( + cards, + Team.BLUE, + Role.OPERATIVE, + Team.RED, + 1, + 0, + 2, + new Clue("ANIMAL", 2), + mockClueValidationService); + + assertEquals(Team.BLUE, restored.getCurrentTurn()); + assertEquals(Role.OPERATIVE, restored.getCurrentPhase()); + assertEquals(2, restored.getRemainingGuesses()); + assertEquals("ANIMAL", restored.getCurrentClueWord()); + assertEquals(Team.RED, restored.getWinner()); + 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 index 9bcbaa8..5c7df39 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java @@ -1,16 +1,23 @@ package com.codenames.codenames.backend.playingfield; -import com.codenames.codenames.backend.serialization.DataTransferObjectService; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; 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.game.dto.GameStateDto; +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 org.junit.jupiter.api.BeforeEach; @@ -21,6 +28,7 @@ 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; @@ -29,7 +37,7 @@ class GameServiceTest { void setup() { mockGameManagerFactory = mock(GameManagerFactory.class); mockGameManager = mock(GameManager.class); - DataTransferObjectService mockDtoService = mock(DataTransferObjectService.class); + mockDtoService = mock(DataTransferObjectService.class); gameService = new GameService(mockGameManagerFactory, mockDtoService); when(mockGameManagerFactory.create(redTeam)).thenReturn(mockGameManager); @@ -58,7 +66,7 @@ void testRemoveGame() { @Test void testSubmitClue() { - Clue mockClue = mock(Clue.class); + Clue mockClue = new Clue("ANIMAL", 2); gameService.submitClue(lobbyCode, mockClue, redTeam); verify(mockGameManager, times(1)).submitClue(mockClue, redTeam); @@ -96,4 +104,43 @@ void testCreateGameStateDto() { assertNotNull(dto); } + + @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(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")); + } } From 7393f5e8a85e56818fefacc560f93eee7ace0001 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 01:01:25 +0200 Subject: [PATCH 17/60] style: reorder imports in LobbyServiceTest --- .../codenames/backend/lobby/services/LobbyServiceTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 46ad282..7b8f787 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,9 +5,6 @@ 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 org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -25,6 +22,9 @@ import java.util.List; 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}. From 885cf5e07035892ab4137293f3fc5123601ae16d Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 01:02:00 +0200 Subject: [PATCH 18/60] test: configure Mockito mock maker for local test stability --- .../resources/mockito-extensions/org.mockito.plugins.MockMaker | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker 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 0000000..fdbd0b1 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass From a0a41de3f75fea47dd9703b0edd20517a891590b Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 01:04:58 +0200 Subject: [PATCH 19/60] docs: add javadocs --- .../backend/lobby/services/LobbyService.java | 6 +++++ .../codenames/backend/playingfield/Board.java | 5 ++++ .../backend/playingfield/GameManager.java | 20 ++++++++++++++ .../playingfield/GameManagerFactory.java | 12 +++++++++ .../backend/playingfield/GameService.java | 6 +++++ .../recovery/SystemStateRecoveryService.java | 27 +++++++++++++++++++ 6 files changed, 76 insertions(+) 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 9106cb0..865bbee 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 @@ -89,6 +89,12 @@ public boolean joinLobby(String username, String 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); } 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 6580bdc..4e9cefc 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/Board.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/Board.java @@ -24,6 +24,11 @@ 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); } 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 26ad5a8..678f763 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java @@ -64,6 +64,19 @@ public GameManager( new Board(cardGenerator, TOTAL_CARDS, redCards, blueCards, WHITE_CARDS, BLACK_CARDS); } + /** + * Constructor used by recovery logic to rebuild an already running game state. + * + * @param cards recovered card list + * @param currentTurn recovered active team + * @param currentPhase recovered active phase + * @param winner recovered winner, if present + * @param currentRedFound recovered red score + * @param currentBlueFound recovered blue score + * @param remainingGuesses recovered remaining guesses + * @param currentClue recovered current clue, if present + * @param clueValidationService clue validation service + */ public GameManager( List cards, Team currentTurn, @@ -95,6 +108,13 @@ public GameManager( this.board = new Board(cards); } + /** + * 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(); } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java index d1608e2..28e4800 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -36,6 +36,12 @@ 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(GameSnapshot snapshot) { Clue clue = snapshot.currentClue() == null @@ -55,6 +61,12 @@ public GameManager createFromSnapshot(GameSnapshot snapshot) { clueValidationService); } + /** + * Maps persisted card DTO representation to runtime {@link Card}. + * + * @param cardDto persisted card payload + * @return runtime card instance + */ private Card toCard(CardDataTransferObject cardDto) { Card card = new Card(cardDto.word(), cardDto.color()); if (cardDto.isGuessed()) { diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java index 59f8d48..896f1cf 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -51,6 +51,12 @@ 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); } diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java index e7a3104..7de5c16 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java @@ -15,6 +15,7 @@ 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 { @@ -24,6 +25,14 @@ public class SystemStateRecoveryService { 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, @@ -35,6 +44,7 @@ public SystemStateRecoveryService( this.gameManagerFactory = gameManagerFactory; } + /** Loads and restores persisted state at startup when a compatible snapshot exists. */ @jakarta.annotation.PostConstruct public void recoverOnStartup() { stateStore @@ -53,6 +63,11 @@ public void recoverOnStartup() { }); } + /** + * Restores all lobby snapshots into {@link LobbyService}. + * + * @param lobbySnapshots persisted lobby snapshots keyed by lobby code + */ private void restoreLobbies(Map lobbySnapshots) { if (lobbySnapshots == null || lobbySnapshots.isEmpty()) { return; @@ -65,6 +80,11 @@ private void restoreLobbies(Map lobbySnapshots) { } } + /** + * Restores all game snapshots into {@link GameService}. + * + * @param gameSnapshots persisted game snapshots keyed by lobby code + */ private void restoreGames(Map gameSnapshots) { if (gameSnapshots == null || gameSnapshots.isEmpty()) { return; @@ -75,6 +95,13 @@ private void restoreGames(Map gameSnapshots) { } } + /** + * Builds a runtime lobby from a persisted lobby snapshot. + * + * @param lobbyCode target lobby code + * @param snapshot persisted lobby snapshot + * @return rebuilt lobby, or {@code null} when snapshot player data is invalid + */ private Lobby buildLobby(String lobbyCode, LobbySnapshot snapshot) { if (snapshot == null || snapshot.players() == null || snapshot.players().isEmpty()) { log.warn("Skipping restore for lobby {} due to missing player data.", lobbyCode); From cad75785dba90d50bfed82fb478c7a5177357b2b Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 12:12:01 +0200 Subject: [PATCH 20/60] test: replace final DTO mocks with concrete instances in controller tests --- .../controller/GameSocketControllerTest.java | 16 ++++++++++------ .../backend/websocket/GameControllerTest.java | 14 ++++++++------ 2 files changed, 18 insertions(+), 12 deletions(-) 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 index a5183a5..eeb8f3c 100644 --- a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -1,9 +1,7 @@ package com.codenames.codenames.backend.game.controller; -import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -12,6 +10,8 @@ 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.serialization.GameStateDataTransferObject; +import java.util.List; import com.codenames.codenames.backend.utility.Team; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -43,7 +43,7 @@ void startGameShouldBroadcastState() { message.setLobbyCode("ABCDE"); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); controller.startGame(message); @@ -59,7 +59,7 @@ void revealCardShouldBroadcastState() { message.setPosition(0); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); controller.revealCard(message); @@ -78,7 +78,7 @@ void submitClueShouldBroadcastState() { message.setGuessAmount(2); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); controller.submitClue(message); @@ -95,7 +95,7 @@ void passTurnShouldBroadcastUpdatedState() { message.setLobbyCode("ABCDE"); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); controller.passTurn(message); @@ -103,4 +103,8 @@ void passTurnShouldBroadcastUpdatedState() { 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/websocket/GameControllerTest.java b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java index 1d52bc0..66ab22c 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import com.codenames.codenames.backend.game.dto.GameStateDto; import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; import java.util.List; @@ -57,8 +58,7 @@ void shouldRegisterJoinAndRegisterSession() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")) - .thenReturn(mock(com.codenames.codenames.backend.game.dto.GameStateDto.class)); + when(gameService.createGameStateDto("ABCDE")).thenReturn(createGameStateDto()); controller.join(msg, accessor); @@ -124,8 +124,7 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")) - .thenReturn(mock(com.codenames.codenames.backend.game.dto.GameStateDto.class)); + when(gameService.createGameStateDto("ABCDE")).thenReturn(createGameStateDto()); controller.join(msg, accessor); @@ -152,8 +151,7 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(false); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")) - .thenReturn(mock(com.codenames.codenames.backend.game.dto.GameStateDto.class)); + when(gameService.createGameStateDto("ABCDE")).thenReturn(createGameStateDto()); controller.join(msg, accessor); @@ -163,4 +161,8 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } + + private GameStateDto createGameStateDto() { + return new GameStateDto(List.of(), null, 0, null, null, null); + } } From 2dc0d4098564ad45878b01c13c30ca31f1f6dcc5 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 12:12:44 +0200 Subject: [PATCH 21/60] style: fix import order --- .../backend/game/controller/GameSocketControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index eeb8f3c..1bc78ef 100644 --- a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -11,8 +11,8 @@ import com.codenames.codenames.backend.game.dto.StartGameMessage; import com.codenames.codenames.backend.playingfield.GameService; import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; -import java.util.List; 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; From 080726d8fd77998cd239e58b096b6bfec2b7d569 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:14:42 +0200 Subject: [PATCH 22/60] refactor: delete unused clue snapshot record --- .../backend/recovery/snapshot/ClueSnapshot.java | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java deleted file mode 100644 index 2e3b2fb..0000000 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/ClueSnapshot.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.codenames.codenames.backend.recovery.snapshot; - -/** - * Persisted clue payload used for restart recovery snapshots. - * - * @param word clue word - * @param guessAmount clue guess amount - */ -public record ClueSnapshot(String word, int guessAmount) {} From a04a04a7050a669db44588bc40a2c9bb96221455 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:15:16 +0200 Subject: [PATCH 23/60] refactor: delete unused lobby snapshot record --- .../backend/recovery/snapshot/LobbySnapshot.java | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java deleted file mode 100644 index 992b00d..0000000 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/LobbySnapshot.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.codenames.codenames.backend.recovery.snapshot; - -import com.codenames.codenames.backend.lobby.dto.PlayerDto; -import java.util.List; - -/** - * Persisted lobby payload used for restart recovery snapshots. - * - * @param lobbyCode lobby identifier - * @param players players including team/role/host metadata - */ -public record LobbySnapshot(String lobbyCode, List players) {} From bd5cf0cb950a484f38ed00cb792ffa3af6c69bb8 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:15:59 +0200 Subject: [PATCH 24/60] refactor: use ClueDto in game snapshot --- .../codenames/backend/recovery/snapshot/GameSnapshot.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java index 871263f..19f8b56 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java @@ -1,5 +1,6 @@ package com.codenames.codenames.backend.recovery.snapshot; +import com.codenames.codenames.backend.game.dto.ClueDto; import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; @@ -14,7 +15,7 @@ * @param currentRedFound discovered red cards * @param currentBlueFound discovered blue cards * @param remainingGuesses remaining guesses - * @param currentClue current clue if present + * @param currentClue current clue DTO if present * @param cards full card state list */ public record GameSnapshot( @@ -24,5 +25,5 @@ public record GameSnapshot( int currentRedFound, int currentBlueFound, int remainingGuesses, - ClueSnapshot currentClue, + ClueDto currentClue, List cards) {} From b9d9f25f4fb8a20a47f84f9abdef5b00f9f54e75 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:16:47 +0200 Subject: [PATCH 25/60] refactor: use player dto list in system snapshot --- .../codenames/backend/recovery/snapshot/SystemSnapshot.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index cdee2c2..0300db5 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java @@ -1,16 +1,18 @@ package com.codenames.codenames.backend.recovery.snapshot; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import java.util.List; import java.util.Map; /** * Root snapshot aggregate for persisted backend runtime state. * * @param schemaVersion persisted schema version - * @param lobbies lobby snapshots keyed by lobby code + * @param lobbies lobby player lists keyed by lobby code * @param games game snapshots keyed by lobby code */ public record SystemSnapshot( - int schemaVersion, Map lobbies, Map games) { + int schemaVersion, Map> lobbies, Map games) { public static final int CURRENT_SCHEMA_VERSION = 1; } From be8573105b685d32796fb38d64c1297eb13d0047 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:26:17 +0200 Subject: [PATCH 26/60] refactor: adapt game manager factory to dto based clue recovery --- .../codenames/backend/playingfield/GameManagerFactory.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java index 28e4800..ea91d15 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -2,6 +2,7 @@ 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.recovery.snapshot.GameSnapshot; import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.utility.Team; @@ -43,10 +44,9 @@ public GameManager create(Team startingTeam) { * @return restored game manager */ public GameManager createFromSnapshot(GameSnapshot snapshot) { + ClueDto clueDto = snapshot.currentClue(); Clue clue = - snapshot.currentClue() == null - ? null - : new Clue(snapshot.currentClue().word(), snapshot.currentClue().guessAmount()); + clueDto == null ? null : new Clue(clueDto.word(), clueDto.guessAmount()); List cards = snapshot.cards().stream().map(this::toCard).toList(); return new GameManager( From 746ceec64fcd2ad1b46add8d7e764441bf2934f1 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:32:20 +0200 Subject: [PATCH 27/60] refactor: adapt lobby recovery to dto based snapshot structure --- .../recovery/SystemStateRecoveryService.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java index 7de5c16..17d94ec 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java @@ -7,7 +7,6 @@ import com.codenames.codenames.backend.playingfield.GameManagerFactory; import com.codenames.codenames.backend.playingfield.GameService; import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; -import com.codenames.codenames.backend.recovery.snapshot.LobbySnapshot; import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; import java.util.Comparator; import java.util.List; @@ -66,13 +65,13 @@ public void recoverOnStartup() { /** * Restores all lobby snapshots into {@link LobbyService}. * - * @param lobbySnapshots persisted lobby snapshots keyed by lobby code + * @param lobbySnapshots persisted lobby player lists keyed by lobby code */ - private void restoreLobbies(Map lobbySnapshots) { + private void restoreLobbies(Map> lobbySnapshots) { if (lobbySnapshots == null || lobbySnapshots.isEmpty()) { return; } - for (Map.Entry entry : lobbySnapshots.entrySet()) { + for (Map.Entry> entry : lobbySnapshots.entrySet()) { Lobby restoredLobby = buildLobby(entry.getKey(), entry.getValue()); if (restoredLobby != null) { lobbyService.restoreLobby(entry.getKey(), restoredLobby); @@ -99,29 +98,29 @@ private void restoreGames(Map gameSnapshots) { * Builds a runtime lobby from a persisted lobby snapshot. * * @param lobbyCode target lobby code - * @param snapshot persisted lobby snapshot - * @return rebuilt lobby, or {@code null} when snapshot player data is invalid + * @param players persisted lobby players + * @return rebuilt lobby, or {@code null} when player data is invalid */ - private Lobby buildLobby(String lobbyCode, LobbySnapshot snapshot) { - if (snapshot == null || snapshot.players() == null || snapshot.players().isEmpty()) { + 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 players = - snapshot.players().stream() + List validPlayers = + players.stream() .filter(player -> player.username() != null && !player.username().isBlank()) .sorted(Comparator.comparing(PlayerDto::isHost).reversed()) .toList(); - if (players.isEmpty()) { + if (validPlayers.isEmpty()) { return null; } - PlayerDto host = players.get(0); + PlayerDto host = validPlayers.get(0); Lobby lobby = new Lobby(lobbyCode, host.username()); - for (PlayerDto player : players) { + for (PlayerDto player : validPlayers) { if (!player.username().equals(host.username())) { lobby.addPlayer(player.username(), player.isHost()); } From 04e823b9dcfed33591059e9252258a29c2b9e671 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:45:55 +0200 Subject: [PATCH 28/60] test: update game manager factory recovery tests --- .../backend/playingfield/GameManagerFactoryTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java index 983433a..ecfe86a 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java @@ -7,7 +7,7 @@ import static org.mockito.Mockito.mock; import com.codenames.codenames.backend.clue.ClueValidationService; -import com.codenames.codenames.backend.recovery.snapshot.ClueSnapshot; +import com.codenames.codenames.backend.game.dto.ClueDto; import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.utility.Color; @@ -46,7 +46,7 @@ void testCreateFromSnapshotWithClue() { 1, 0, 2, - new ClueSnapshot("ANIMAL", 2), + new ClueDto("ANIMAL", 2), List.of( new CardDataTransferObject("Dog", Color.RED, true), new CardDataTransferObject("Cat", Color.BLUE, false))); From 5d05310cb691a93fd4730c03412320e7415dbb2d Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:48:39 +0200 Subject: [PATCH 29/60] test: update json state store tests for dto snapshots --- .../backend/recovery/JsonStateStoreTest.java | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index 8f1afa6..b83dcea 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -6,10 +6,9 @@ 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.ClueSnapshot; import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; -import com.codenames.codenames.backend.recovery.snapshot.LobbySnapshot; import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; @@ -53,21 +52,19 @@ void saveAndLoadRoundTrip() { Path stateFile = tempDir.resolve("state.json"); JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); - LobbySnapshot lobbySnapshot = - new LobbySnapshot( - "ABCDE", - List.of( - new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), - new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false))); + List lobbyPlayers = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); GameSnapshot gameSnapshot = new GameSnapshot( - Team.RED, Role.OPERATIVE, null, 1, 0, 2, new ClueSnapshot("ANIMAL", 2), List.of()); + Team.RED, Role.OPERATIVE, null, 1, 0, 2, new ClueDto("ANIMAL", 2), List.of()); SystemSnapshot expectedSnapshot = new SystemSnapshot( SystemSnapshot.CURRENT_SCHEMA_VERSION, - Map.of("ABCDE", lobbySnapshot), + Map.of("ABCDE", lobbyPlayers), Map.of("ABCDE", gameSnapshot)); stateStore.save(expectedSnapshot); @@ -81,13 +78,12 @@ void saveAndLoadRoundTrip() { assertEquals(1, actualSnapshot.lobbies().size()); assertEquals(1, actualSnapshot.games().size()); - LobbySnapshot actualLobbySnapshot = actualSnapshot.lobbies().get("ABCDE"); - assertEquals("ABCDE", actualLobbySnapshot.lobbyCode()); - assertEquals(2, actualLobbySnapshot.players().size()); - assertEquals("Host", actualLobbySnapshot.players().get(0).username()); - assertEquals(Team.RED, actualLobbySnapshot.players().get(0).team()); - assertEquals(Role.SPYMASTER, actualLobbySnapshot.players().get(0).role()); - assertTrue(actualLobbySnapshot.players().get(0).isHost()); + 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()); GameSnapshot actualGameSnapshot = actualSnapshot.games().get("ABCDE"); assertEquals(Team.RED, actualGameSnapshot.currentTurn()); @@ -108,7 +104,7 @@ void saveOverwritesPreviousSnapshot() { SystemSnapshot secondSnapshot = new SystemSnapshot( SystemSnapshot.CURRENT_SCHEMA_VERSION, - Map.of("ABCDE", new LobbySnapshot("ABCDE", List.of())), + Map.of("ABCDE", List.of()), Map.of()); stateStore.save(firstSnapshot); From 510688ec2b01923659fa18b121c891fe7fd3ee5b Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 17:48:50 +0200 Subject: [PATCH 30/60] test: update system state recovery tests for dto snapshots --- .../SystemStateRecoveryServiceTest.java | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java index 87f59ee..32c9602 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java @@ -7,6 +7,7 @@ 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; @@ -15,9 +16,7 @@ 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.ClueSnapshot; import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; -import com.codenames.codenames.backend.recovery.snapshot.LobbySnapshot; import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.serialization.DataTransferObjectService; @@ -62,12 +61,10 @@ void recoverOnStartupSkipsSnapshotWhenSchemaVersionDiffers() { @Test void recoverOnStartupRestoresLobbiesAndGamesFromSnapshot() { TestContext context = createContext(tempDir.resolve("state.json")); - LobbySnapshot lobbySnapshot = - new LobbySnapshot( - "ABCDE", - List.of( - new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), - new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false))); + List lobbyPlayers = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); GameSnapshot gameSnapshot = new GameSnapshot( Team.RED, @@ -76,14 +73,14 @@ void recoverOnStartupRestoresLobbiesAndGamesFromSnapshot() { 1, 0, 2, - new ClueSnapshot("ANIMAL", 2), + 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", lobbySnapshot), + Map.of("ABCDE", lobbyPlayers), Map.of("ABCDE", gameSnapshot)); context.stateStore().save(snapshot); @@ -122,8 +119,7 @@ void recoverOnStartupWithCurrentSchemaAndEmptyMapsDoesNothing() { @Test void recoverOnStartupHandlesNullLobbyAndGameMaps() { TestContext context = createContext(tempDir.resolve("state-null-maps.json")); - SystemSnapshot snapshot = - new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, null, null); + SystemSnapshot snapshot = new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, null, null); context.stateStore().save(snapshot); context.recoveryService().recoverOnStartup(); @@ -135,7 +131,7 @@ void recoverOnStartupHandlesNullLobbyAndGameMaps() { @Test void recoverOnStartupSkipsLobbyWhenSnapshotEntryIsNull() { TestContext context = createContext(tempDir.resolve("state-null-lobby-entry.json")); - Map lobbies = new HashMap<>(); + Map> lobbies = new HashMap<>(); lobbies.put("ABCDE", null); SystemSnapshot snapshot = new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, lobbies, Map.of()); @@ -149,10 +145,10 @@ void recoverOnStartupSkipsLobbyWhenSnapshotEntryIsNull() { @Test void recoverOnStartupSkipsLobbyWhenPlayersListIsNull() { TestContext context = createContext(tempDir.resolve("state-null-players.json")); - LobbySnapshot lobbySnapshot = new LobbySnapshot("ABCDE", null); + Map> lobbies = new HashMap<>(); + lobbies.put("ABCDE", null); SystemSnapshot snapshot = - new SystemSnapshot( - SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, lobbies, Map.of()); context.stateStore().save(snapshot); context.recoveryService().recoverOnStartup(); @@ -163,7 +159,7 @@ void recoverOnStartupSkipsLobbyWhenPlayersListIsNull() { @Test void recoverOnStartupSkipsLobbyWhenPlayersListIsEmpty() { TestContext context = createContext(tempDir.resolve("state-empty-players.json")); - LobbySnapshot lobbySnapshot = new LobbySnapshot("ABCDE", List.of()); + List lobbySnapshot = List.of(); SystemSnapshot snapshot = new SystemSnapshot( SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); @@ -177,12 +173,10 @@ void recoverOnStartupSkipsLobbyWhenPlayersListIsEmpty() { @Test void recoverOnStartupSkipsLobbyWhenAllUsernamesAreInvalid() { TestContext context = createContext(tempDir.resolve("state-invalid-usernames.json")); - LobbySnapshot lobbySnapshot = - new LobbySnapshot( - "ABCDE", - List.of( - new PlayerDto(" ", Team.RED, Role.SPYMASTER, true), - new PlayerDto(null, Team.BLUE, Role.OPERATIVE, false))); + 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()); @@ -196,12 +190,10 @@ void recoverOnStartupSkipsLobbyWhenAllUsernamesAreInvalid() { @Test void recoverOnStartupRestoresLobbyWhenSomeTeamOrRoleValuesAreMissing() { TestContext context = createContext(tempDir.resolve("state-missing-team-role.json")); - LobbySnapshot lobbySnapshot = - new LobbySnapshot( - "ABCDE", - List.of( - new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), - new PlayerDto("Player", null, null, false))); + 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()); @@ -243,8 +235,7 @@ void recoverOnStartupRestoresOnlyGamesWhenLobbiesMapIsNull() { @Test void recoverOnStartupRestoresOnlyLobbiesWhenGamesMapIsNull() { TestContext context = createContext(tempDir.resolve("state-games-null-lobbies-present.json")); - LobbySnapshot lobbySnapshot = - new LobbySnapshot("ABCDE", List.of(new PlayerDto("Host", Team.RED, Role.SPYMASTER, true))); + 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); From 7b8d7628f8620432b3c7269aed014e1dff6b2275 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:08:55 +0200 Subject: [PATCH 31/60] test: simplify assertThrows lambdas in recovery constructor tests --- .../backend/playingfield/GameManagerTest.java | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) 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 dcfa984..2ecd375 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java @@ -336,66 +336,39 @@ void recoveryConstructorThrowsWhenCardsAreNull() { IllegalArgumentException.class, () -> new GameManager( - null, - Team.RED, - Role.SPYMASTER, - null, - 0, - 0, - 0, - null, - mockClueValidationService)); + null, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null, mockClueValidationService)); } @Test void recoveryConstructorThrowsWhenCardsAreEmpty() { + List cards = List.of(); + assertThrows( IllegalArgumentException.class, () -> new GameManager( - List.of(), - Team.RED, - Role.SPYMASTER, - null, - 0, - 0, - 0, - null, - mockClueValidationService)); + cards, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null, mockClueValidationService)); } @Test void recoveryConstructorThrowsWhenCurrentTurnIsNull() { + List cards = List.of(new Card("Dog", Color.RED)); + assertThrows( IllegalArgumentException.class, () -> new GameManager( - List.of(new Card("Dog", Color.RED)), - null, - Role.SPYMASTER, - null, - 0, - 0, - 0, - null, - mockClueValidationService)); + cards, null, Role.SPYMASTER, null, 0, 0, 0, null, mockClueValidationService)); } @Test void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { + List cards = List.of(new Card("Dog", Color.RED)); + assertThrows( IllegalArgumentException.class, () -> - new GameManager( - List.of(new Card("Dog", Color.RED)), - Team.RED, - null, - null, - 0, - 0, - 0, - null, - mockClueValidationService)); + new GameManager(cards, Team.RED, null, null, 0, 0, 0, null, mockClueValidationService)); } @Test From 9278789454032eb03761cf6f5c772f87a90844e5 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:27:26 +0200 Subject: [PATCH 32/60] refactor: add remaining guesses to game state transfer object --- .../backend/serialization/GameStateDataTransferObject.java | 3 +++ 1 file changed, 3 insertions(+) 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 37f1853..54eb2c7 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java @@ -10,7 +10,9 @@ * * @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 remainingGuesses amount of guesses left for the current turn * @param cardList the cards on the board */ public record GameStateDataTransferObject( @@ -18,4 +20,5 @@ public record GameStateDataTransferObject( Team currentTurn, Role currentPhase, ClueDto currentClue, + int remainingGuesses, List cardList) {} From f7f4d0c90e45a59a1aaf80f9edcf771782dc7a45 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:28:29 +0200 Subject: [PATCH 33/60] refactor: map remaining guesses in dto service --- .../backend/serialization/DataTransferObjectService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 a282acf..d1dfb13 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java @@ -33,7 +33,7 @@ private CardDataTransferObject createCardDataTransferObject(Card card) { * @return a DTO of the current game state */ public GameStateDataTransferObject createGameStateDataTransferObject( - GameManager gameManager, Team currentTurn, Role currentPhase) { + GameManager gameManager, Team currentTurn, Role currentPhase) { List cardList = gameManager.getCardList(); List cardDataTransferObject = new ArrayList<>(); @@ -46,6 +46,7 @@ public GameStateDataTransferObject createGameStateDataTransferObject( currentTurn, currentPhase, null, + gameManager.getRemainingGuesses(), cardDataTransferObject); } String word = gameManager.getCurrentClue().word(); @@ -55,6 +56,7 @@ public GameStateDataTransferObject createGameStateDataTransferObject( currentTurn, currentPhase, new ClueDto(word, guessAmount), + gameManager.getRemainingGuesses(), cardDataTransferObject); } } From 1240f532f88eee93c3aa0c300355f862da7973c3 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:29:55 +0200 Subject: [PATCH 34/60] refactor: remove legacy game state dto creation from game service --- .../backend/playingfield/GameService.java | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java index 896f1cf..4488f70 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -1,7 +1,6 @@ package com.codenames.codenames.backend.playingfield; import com.codenames.codenames.backend.clue.Clue; -import com.codenames.codenames.backend.game.dto.GameStateDto; import com.codenames.codenames.backend.serialization.DataTransferObjectService; import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Team; @@ -119,25 +118,6 @@ public void passTurn(String lobbyCode, Team callingTeam) { gm.passTurn(callingTeam); } - /** - * Creates a DTO representing the current game state. - * - * @param lobbyCode lobby identifier - * @return DTO containing board and turn information - */ - public GameStateDto createGameStateDto(String lobbyCode) { - - GameManager gm = getGame(lobbyCode); - - return new GameStateDto( - gm.getCardList(), - gm.getCurrentClue(), - gm.getRemainingGuesses(), - gm.getWinner(), - gm.getCurrentTurn(), - gm.getCurrentPhase()); - } - /** * Maps the current game state into a @link GameStateTransferObject. * From 23e1358c17e41547f433bdb03e0b0e092b095efe Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:31:54 +0200 Subject: [PATCH 35/60] refactor: use unified game state payload in game controller --- .../codenames/codenames/backend/websocket/GameController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b386568..db30cfc 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java @@ -101,6 +101,6 @@ private void sendPlayerUpdate(String code) { * @param code the lobby code identifying the game */ private void sendGameStateUpdate(String code) { - messagingTemplate.convertAndSend("/topic/game/" + code, gameService.createGameStateDto(code)); + messagingTemplate.convertAndSend("/topic/game/" + code, gameService.getCurrentGameState(code)); } } From 79b01a8169fd2d1f7d3b61307e696ee8b61eab82 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:32:15 +0200 Subject: [PATCH 36/60] refactor: remove unused game state dto record --- .../backend/game/dto/GameStateDto.java | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java b/src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java deleted file mode 100644 index 010b8ef..0000000 --- a/src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.codenames.codenames.backend.game.dto; - -import com.codenames.codenames.backend.clue.Clue; -import com.codenames.codenames.backend.playingfield.Card; -import com.codenames.codenames.backend.utility.Role; -import com.codenames.codenames.backend.utility.Team; -import java.util.List; - -/** - * DTO representing the current game state. - */ -public record GameStateDto( - List cards, - Clue currentClue, - int remainingGuesses, - Team winner, - Team currentTurn, - Role currentPhase -) {} From 6abec7644f0a3c6ecc3bf036d58ac9b8cc282a83 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:35:19 +0200 Subject: [PATCH 37/60] test: update game service tests for unified payload --- .../backend/playingfield/GameServiceTest.java | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java index 5c7df39..6389d6b 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java @@ -2,7 +2,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -12,7 +11,6 @@ import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.game.dto.ClueDto; -import com.codenames.codenames.backend.game.dto.GameStateDto; import com.codenames.codenames.backend.serialization.CardDataTransferObject; import com.codenames.codenames.backend.serialization.DataTransferObjectService; import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; @@ -95,16 +93,6 @@ void testGetGameState() { assertEquals(mockGameManager, result); } - @Test - void testCreateGameStateDto() { - - when(mockGameManager.getCardList()).thenReturn(List.of()); - - GameStateDto dto = gameService.createGameStateDto(lobbyCode); - - assertNotNull(dto); - } - @Test void testRestoreGameManager() { String restoredLobbyCode = "VWXYZ"; @@ -123,9 +111,11 @@ void testGetCurrentGameState() { redTeam, Role.SPYMASTER, new ClueDto("ANIMAL", 2), + 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); From 82481fce7c30f381b5751900a9c9fe0253b78ebd Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:36:39 +0200 Subject: [PATCH 38/60] test: update game controller tests for unified payload --- .../backend/websocket/GameControllerTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 66ab22c..a963795 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -9,7 +9,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import com.codenames.codenames.backend.game.dto.GameStateDto; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; import java.util.List; @@ -58,7 +58,7 @@ void shouldRegisterJoinAndRegisterSession() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")).thenReturn(createGameStateDto()); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -124,7 +124,7 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")).thenReturn(createGameStateDto()); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -151,7 +151,7 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(false); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")).thenReturn(createGameStateDto()); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -162,7 +162,7 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } - private GameStateDto createGameStateDto() { - return new GameStateDto(List.of(), null, 0, null, null, null); + private GameStateDataTransferObject createGameStatePayload() { + return new GameStateDataTransferObject(null, null, null, null, 0, List.of()); } } From 414b1893db175b7005770b38832747b0a8e11ec3 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:38:50 +0200 Subject: [PATCH 39/60] test: update game socket controller test payload constructor --- .../backend/game/controller/GameSocketControllerTest.java | 2 +- .../codenames/backend/websocket/GameControllerTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 1bc78ef..a5166d2 100644 --- a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -105,6 +105,6 @@ void passTurnShouldBroadcastUpdatedState() { } private GameStateDataTransferObject createGameStateDataTransferObject() { - return new GameStateDataTransferObject(null, Team.RED, null, null, List.of()); + return new GameStateDataTransferObject(null, Team.RED, null, null, 0, List.of()); } } 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 a963795..192a9d7 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -9,9 +9,9 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; -import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; 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; From 27e903c5eef04212cc576080c55fa9ca8fd90b67 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:39:38 +0200 Subject: [PATCH 40/60] test: update serialization dto service tests for remaining guesses --- .../serialization/DataTransferObjectServiceTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 5b84871..0097b0c 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java @@ -37,6 +37,7 @@ void setUp() { 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); @@ -78,8 +79,10 @@ void testCreateGameStateDataTransferObject() { 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); + GameStateDataTransferObject dto = + service.createGameStateDataTransferObject(mockGameManager, redTeam, operative); assertEquals(clue.word(), dto.currentClue().word()); assertEquals(clue.guessAmount(), dto.currentClue().guessAmount()); @@ -87,5 +90,6 @@ void testCreateGameStateDataTransferObject() { assertEquals(0, dto.cardList().size()); assertEquals(redTeam, dto.currentTurn()); assertEquals(operative, dto.currentPhase()); + assertEquals(3, dto.remainingGuesses()); } } From ccfb59c472d059418d4fdf82218f2f5d2ff060ed Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 19 May 2026 19:40:27 +0200 Subject: [PATCH 41/60] test: update serialization dto service tests for remaining guesses --- .../backend/serialization/SerializationJsonTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 5f6837d..a82de2b 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java @@ -34,14 +34,15 @@ void setUp() { dummyList = List.of(new CardDataTransferObject("TEST", null, false)); dummyGameState = - new GameStateDataTransferObject(redTeam, redTeam, spymaster, new ClueDto("Test", 1), dummyList); + new GameStateDataTransferObject( + redTeam, redTeam, spymaster, new ClueDto("Test", 1), 1, dummyList); } @Test void testSerialize_pass() { String expectedResult = """ - {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}""" + {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"remainingGuesses":1,"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}""" ; String result = serializer.serialize(dummyGameState); assertEquals(expectedResult, result); From c9af76c25ce15dac804da18d6bd6f187645a3111 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 00:06:10 +0200 Subject: [PATCH 42/60] refactor: unify game recovery state dto --- .../backend/playingfield/GameManager.java | 44 ++++++------------- .../playingfield/GameManagerFactory.java | 38 +++++++++------- .../playingfield/GameRecoveryState.java | 28 ++++++++++++ .../recovery/SystemStateRecoveryService.java | 8 ++-- .../recovery/snapshot/GameSnapshot.java | 29 ------------ .../recovery/snapshot/SystemSnapshot.java | 9 ++-- 6 files changed, 75 insertions(+), 81 deletions(-) create mode 100644 src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java delete mode 100644 src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java 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 678f763..f4908d3 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java @@ -67,45 +67,29 @@ public GameManager( /** * Constructor used by recovery logic to rebuild an already running game state. * - * @param cards recovered card list - * @param currentTurn recovered active team - * @param currentPhase recovered active phase - * @param winner recovered winner, if present - * @param currentRedFound recovered red score - * @param currentBlueFound recovered blue score - * @param remainingGuesses recovered remaining guesses - * @param currentClue recovered current clue, if present + * @param state bundled recovery state * @param clueValidationService clue validation service */ - public GameManager( - List cards, - Team currentTurn, - Role currentPhase, - Team winner, - int currentRedFound, - int currentBlueFound, - int remainingGuesses, - Clue currentClue, - ClueValidationService clueValidationService) { - if (cards == null || cards.isEmpty()) { + public GameManager(GameRecoveryState state, ClueValidationService clueValidationService) { + if (state.cards() == null || state.cards().isEmpty()) { throw new IllegalArgumentException("cards cannot be null or empty"); } - if (currentTurn == null || currentPhase == null) { + if (state.currentTurn() == null || state.currentPhase() == null) { throw new IllegalArgumentException("current turn and phase cannot be null"); } - this.currentTurn = currentTurn; - this.currentPhase = currentPhase; - this.winner = winner; - this.currentRedFound = currentRedFound; - this.currentBlueFound = currentBlueFound; - this.remainingGuesses = remainingGuesses; - this.currentClue = currentClue; + this.currentTurn = state.currentTurn(); + this.currentPhase = state.currentPhase(); + this.winner = state.winner(); + this.currentRedFound = state.currentRedFound(); + this.currentBlueFound = state.currentBlueFound(); + this.remainingGuesses = state.remainingGuesses(); + this.currentClue = state.currentClue(); this.clueValidationService = clueValidationService; - this.redCards = countCardsByColor(cards, Color.RED); - this.blueCards = countCardsByColor(cards, Color.BLUE); - this.board = new Board(cards); + this.redCards = countCardsByColor(state.cards(), Color.RED); + this.blueCards = countCardsByColor(state.cards(), Color.BLUE); + this.board = new Board(state.cards()); } /** diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java index ea91d15..7217836 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -3,8 +3,9 @@ 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.recovery.snapshot.GameSnapshot; 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.Team; import java.util.List; import org.springframework.stereotype.Component; @@ -43,22 +44,24 @@ public GameManager create(Team startingTeam) { * @param snapshot persisted game state snapshot * @return restored game manager */ - public GameManager createFromSnapshot(GameSnapshot snapshot) { + public GameManager createFromSnapshot(GameStateDataTransferObject snapshot) { ClueDto clueDto = snapshot.currentClue(); - Clue clue = - clueDto == null ? null : new Clue(clueDto.word(), clueDto.guessAmount()); - List cards = snapshot.cards().stream().map(this::toCard).toList(); + Clue clue = clueDto == null ? null : new Clue(clueDto.word(), clueDto.guessAmount()); + List cards = snapshot.cardList().stream().map(this::toCard).toList(); + int recoveredRedFound = countGuessedByColor(cards, Color.RED); + int recoveredBlueFound = countGuessedByColor(cards, Color.BLUE); + GameRecoveryState recoveryState = + new GameRecoveryState( + cards, + snapshot.currentTurn(), + snapshot.currentPhase(), + snapshot.winner(), + recoveredRedFound, + recoveredBlueFound, + snapshot.remainingGuesses(), + clue); - return new GameManager( - cards, - snapshot.currentTurn(), - snapshot.currentPhase(), - snapshot.winner(), - snapshot.currentRedFound(), - snapshot.currentBlueFound(), - snapshot.remainingGuesses(), - clue, - clueValidationService); + return new GameManager(recoveryState, clueValidationService); } /** @@ -74,4 +77,9 @@ private Card toCard(CardDataTransferObject cardDto) { } return card; } + + private int countGuessedByColor(List cards, Color color) { + return (int) + cards.stream().filter(card -> card.isGuessed() && card.getColor() == color).count(); + } } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java new file mode 100644 index 0000000..3f7693c --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java @@ -0,0 +1,28 @@ +package com.codenames.codenames.backend.playingfield; + +import com.codenames.codenames.backend.clue.Clue; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import java.util.List; + +/** + * Compact recovery payload used to rebuild a {@link GameManager} after restart. + * + * @param cards recovered board cards + * @param currentTurn recovered active team + * @param currentPhase recovered active role phase + * @param winner recovered winner if game already ended + * @param currentRedFound recovered count of discovered red cards + * @param currentBlueFound recovered count of discovered blue cards + * @param remainingGuesses recovered remaining guesses + * @param currentClue recovered clue if present + */ +public record GameRecoveryState( + List cards, + Team currentTurn, + Role currentPhase, + Team winner, + int currentRedFound, + int currentBlueFound, + int remainingGuesses, + Clue currentClue) {} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java index 17d94ec..1425196 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java @@ -6,8 +6,8 @@ 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.GameSnapshot; 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; @@ -82,13 +82,13 @@ private void restoreLobbies(Map> lobbySnapshots) { /** * Restores all game snapshots into {@link GameService}. * - * @param gameSnapshots persisted game snapshots keyed by lobby code + * @param gameSnapshots persisted game states keyed by lobby code */ - private void restoreGames(Map gameSnapshots) { + private void restoreGames(Map gameSnapshots) { if (gameSnapshots == null || gameSnapshots.isEmpty()) { return; } - for (Map.Entry entry : gameSnapshots.entrySet()) { + for (Map.Entry entry : gameSnapshots.entrySet()) { GameManager restoredGame = gameManagerFactory.createFromSnapshot(entry.getValue()); gameService.restoreGameManager(entry.getKey(), restoredGame); } diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java deleted file mode 100644 index 19f8b56..0000000 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/GameSnapshot.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.codenames.codenames.backend.recovery.snapshot; - -import com.codenames.codenames.backend.game.dto.ClueDto; -import com.codenames.codenames.backend.serialization.CardDataTransferObject; -import com.codenames.codenames.backend.utility.Role; -import com.codenames.codenames.backend.utility.Team; -import java.util.List; - -/** - * Persisted game payload used for restart recovery snapshots. - * - * @param currentTurn current team turn - * @param currentPhase current gameplay phase - * @param winner winner if already decided - * @param currentRedFound discovered red cards - * @param currentBlueFound discovered blue cards - * @param remainingGuesses remaining guesses - * @param currentClue current clue DTO if present - * @param cards full card state list - */ -public record GameSnapshot( - Team currentTurn, - Role currentPhase, - Team winner, - int currentRedFound, - int currentBlueFound, - int remainingGuesses, - ClueDto currentClue, - List cards) {} 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 index 0300db5..3ae69bb 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java @@ -1,6 +1,7 @@ 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; @@ -9,10 +10,12 @@ * * @param schemaVersion persisted schema version * @param lobbies lobby player lists keyed by lobby code - * @param games game snapshots keyed by lobby code + * @param games game states keyed by lobby code */ public record SystemSnapshot( - int schemaVersion, Map> lobbies, Map games) { + int schemaVersion, + Map> lobbies, + Map games) { - public static final int CURRENT_SCHEMA_VERSION = 1; + public static final int CURRENT_SCHEMA_VERSION = 2; } From 6c242f7604e865bc976469be6fa1dac199859624 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 00:06:54 +0200 Subject: [PATCH 43/60] test: update recovery tests for unified game state dto --- .../playingfield/GameManagerFactoryTest.java | 24 ++++------- .../backend/playingfield/GameManagerTest.java | 43 ++++++++----------- .../backend/recovery/JsonStateStoreTest.java | 12 +++--- .../SystemStateRecoveryServiceTest.java | 20 ++++----- .../serialization/SerializationJsonTest.java | 5 +-- 5 files changed, 41 insertions(+), 63 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java index ecfe86a..5a4143f 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java @@ -8,8 +8,8 @@ import com.codenames.codenames.backend.clue.ClueValidationService; import com.codenames.codenames.backend.game.dto.ClueDto; -import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; 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; @@ -38,15 +38,13 @@ void testCreate() { @Test void testCreateFromSnapshotWithClue() { - GameSnapshot snapshot = - new GameSnapshot( + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + null, Team.RED, Role.OPERATIVE, - null, - 1, - 0, - 2, new ClueDto("ANIMAL", 2), + 2, List.of( new CardDataTransferObject("Dog", Color.RED, true), new CardDataTransferObject("Cat", Color.BLUE, false))); @@ -61,15 +59,9 @@ void testCreateFromSnapshotWithClue() { @Test void testCreateFromSnapshotWithoutClue() { - GameSnapshot snapshot = - new GameSnapshot( - Team.BLUE, - Role.SPYMASTER, - null, - 0, - 0, - 0, - null, + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + null, Team.BLUE, Role.SPYMASTER, null, 0, List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); 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 2ecd375..644f4db 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java @@ -332,43 +332,41 @@ void testPassTurn_throwsWhenSpymaster() { @Test void recoveryConstructorThrowsWhenCardsAreNull() { + GameRecoveryState state = + new GameRecoveryState(null, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null); + assertThrows( - IllegalArgumentException.class, - () -> - new GameManager( - null, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null, mockClueValidationService)); + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); } @Test void recoveryConstructorThrowsWhenCardsAreEmpty() { List cards = List.of(); + GameRecoveryState state = + new GameRecoveryState(cards, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null); assertThrows( - IllegalArgumentException.class, - () -> - new GameManager( - cards, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null, mockClueValidationService)); + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); } @Test void recoveryConstructorThrowsWhenCurrentTurnIsNull() { List cards = List.of(new Card("Dog", Color.RED)); + GameRecoveryState state = + new GameRecoveryState(cards, null, Role.SPYMASTER, null, 0, 0, 0, null); assertThrows( - IllegalArgumentException.class, - () -> - new GameManager( - cards, null, Role.SPYMASTER, null, 0, 0, 0, null, mockClueValidationService)); + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); } @Test void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { List cards = List.of(new Card("Dog", Color.RED)); + GameRecoveryState state = + new GameRecoveryState(cards, Team.RED, null, null, 0, 0, 0, null); assertThrows( - IllegalArgumentException.class, - () -> - new GameManager(cards, Team.RED, null, null, 0, 0, 0, null, mockClueValidationService)); + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); } @Test @@ -379,17 +377,10 @@ void recoveryConstructorRestoresPersistedState() { cards.add(guessedRed); cards.add(new Card("Cat", Color.BLUE)); - GameManager restored = - new GameManager( - cards, - Team.BLUE, - Role.OPERATIVE, - Team.RED, - 1, - 0, - 2, - new Clue("ANIMAL", 2), - mockClueValidationService); + GameRecoveryState state = + new GameRecoveryState( + cards, Team.BLUE, Role.OPERATIVE, Team.RED, 1, 0, 2, new Clue("ANIMAL", 2)); + GameManager restored = new GameManager(state, mockClueValidationService); assertEquals(Team.BLUE, restored.getCurrentTurn()); assertEquals(Role.OPERATIVE, restored.getCurrentPhase()); diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java index b83dcea..86abe92 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -8,8 +8,8 @@ import com.codenames.codenames.backend.game.dto.ClueDto; import com.codenames.codenames.backend.lobby.dto.PlayerDto; -import com.codenames.codenames.backend.recovery.snapshot.GameSnapshot; 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; @@ -57,9 +57,9 @@ void saveAndLoadRoundTrip() { new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); - GameSnapshot gameSnapshot = - new GameSnapshot( - Team.RED, Role.OPERATIVE, null, 1, 0, 2, new ClueDto("ANIMAL", 2), List.of()); + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, Team.RED, Role.OPERATIVE, new ClueDto("ANIMAL", 2), 2, List.of()); SystemSnapshot expectedSnapshot = new SystemSnapshot( @@ -85,13 +85,13 @@ void saveAndLoadRoundTrip() { assertEquals(Role.SPYMASTER, actualPlayers.get(0).role()); assertTrue(actualPlayers.get(0).isHost()); - GameSnapshot actualGameSnapshot = actualSnapshot.games().get("ABCDE"); + GameStateDataTransferObject actualGameSnapshot = actualSnapshot.games().get("ABCDE"); assertEquals(Team.RED, actualGameSnapshot.currentTurn()); assertEquals(Role.OPERATIVE, actualGameSnapshot.currentPhase()); assertEquals(2, actualGameSnapshot.remainingGuesses()); assertEquals("ANIMAL", actualGameSnapshot.currentClue().word()); assertEquals(2, actualGameSnapshot.currentClue().guessAmount()); - assertTrue(actualGameSnapshot.cards().isEmpty()); + assertTrue(actualGameSnapshot.cardList().isEmpty()); } @Test diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java index 32c9602..30a757b 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java @@ -16,10 +16,10 @@ 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.GameSnapshot; 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; @@ -65,15 +65,13 @@ void recoverOnStartupRestoresLobbiesAndGamesFromSnapshot() { List.of( new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); - GameSnapshot gameSnapshot = - new GameSnapshot( + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, Team.RED, Role.OPERATIVE, - null, - 1, - 0, - 2, new ClueDto("ANIMAL", 2), + 2, List.of( new CardDataTransferObject("Dog", Color.RED, true), new CardDataTransferObject("Cat", Color.BLUE, false))); @@ -211,15 +209,13 @@ void recoverOnStartupRestoresLobbyWhenSomeTeamOrRoleValuesAreMissing() { @Test void recoverOnStartupRestoresOnlyGamesWhenLobbiesMapIsNull() { TestContext context = createContext(tempDir.resolve("state-lobbies-null-games-present.json")); - GameSnapshot gameSnapshot = - new GameSnapshot( + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, Team.BLUE, Role.SPYMASTER, null, 0, - 0, - 0, - null, List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); SystemSnapshot snapshot = new SystemSnapshot( 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 a82de2b..403ee7f 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java @@ -41,9 +41,8 @@ void setUp() { @Test void testSerialize_pass() { String expectedResult = - """ - {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"remainingGuesses":1,"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}""" - ; + """ + {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"remainingGuesses":1,"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}"""; String result = serializer.serialize(dummyGameState); assertEquals(expectedResult, result); } From 4b8387d4b9b53cfafe2a2022656a50eee3728f55 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 00:34:50 +0200 Subject: [PATCH 44/60] refactor: remove redundant game recovery state --- .../backend/playingfield/GameManager.java | 38 +++++++++++++---- .../playingfield/GameManagerFactory.java | 42 +------------------ 2 files changed, 31 insertions(+), 49 deletions(-) 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 f4908d3..ee000c4 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java @@ -2,6 +2,8 @@ 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; @@ -70,8 +72,9 @@ public GameManager( * @param state bundled recovery state * @param clueValidationService clue validation service */ - public GameManager(GameRecoveryState state, ClueValidationService clueValidationService) { - if (state.cards() == null || state.cards().isEmpty()) { + 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) { @@ -81,15 +84,34 @@ public GameManager(GameRecoveryState state, ClueValidationService clueValidation this.currentTurn = state.currentTurn(); this.currentPhase = state.currentPhase(); this.winner = state.winner(); - this.currentRedFound = state.currentRedFound(); - this.currentBlueFound = state.currentBlueFound(); this.remainingGuesses = state.remainingGuesses(); - this.currentClue = state.currentClue(); + this.currentClue = + state.currentClue() == null + ? null + : new Clue(state.currentClue().word(), state.currentClue().guessAmount()); this.clueValidationService = clueValidationService; - this.redCards = countCardsByColor(state.cards(), Color.RED); - this.blueCards = countCardsByColor(state.cards(), Color.BLUE); - this.board = new Board(state.cards()); + 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(); } /** diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java index 7217836..63b468a 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -1,13 +1,8 @@ package com.codenames.codenames.backend.playingfield; -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.Team; -import java.util.List; import org.springframework.stereotype.Component; /** Generates GameManager instances to be used by GameService. */ @@ -45,41 +40,6 @@ public GameManager create(Team startingTeam) { * @return restored game manager */ public GameManager createFromSnapshot(GameStateDataTransferObject snapshot) { - ClueDto clueDto = snapshot.currentClue(); - Clue clue = clueDto == null ? null : new Clue(clueDto.word(), clueDto.guessAmount()); - List cards = snapshot.cardList().stream().map(this::toCard).toList(); - int recoveredRedFound = countGuessedByColor(cards, Color.RED); - int recoveredBlueFound = countGuessedByColor(cards, Color.BLUE); - GameRecoveryState recoveryState = - new GameRecoveryState( - cards, - snapshot.currentTurn(), - snapshot.currentPhase(), - snapshot.winner(), - recoveredRedFound, - recoveredBlueFound, - snapshot.remainingGuesses(), - clue); - - return new GameManager(recoveryState, clueValidationService); - } - - /** - * Maps persisted card DTO representation to runtime {@link Card}. - * - * @param cardDto persisted card payload - * @return runtime card instance - */ - 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(); + return new GameManager(snapshot, clueValidationService); } } From 5c74a0c8e3be77dbacd97a69588413978e9d10a6 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 00:36:45 +0200 Subject: [PATCH 45/60] refactor: remove GameRecoveryState and use game state dto directly --- .../playingfield/GameRecoveryState.java | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java deleted file mode 100644 index 3f7693c..0000000 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameRecoveryState.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.codenames.codenames.backend.playingfield; - -import com.codenames.codenames.backend.clue.Clue; -import com.codenames.codenames.backend.utility.Role; -import com.codenames.codenames.backend.utility.Team; -import java.util.List; - -/** - * Compact recovery payload used to rebuild a {@link GameManager} after restart. - * - * @param cards recovered board cards - * @param currentTurn recovered active team - * @param currentPhase recovered active role phase - * @param winner recovered winner if game already ended - * @param currentRedFound recovered count of discovered red cards - * @param currentBlueFound recovered count of discovered blue cards - * @param remainingGuesses recovered remaining guesses - * @param currentClue recovered clue if present - */ -public record GameRecoveryState( - List cards, - Team currentTurn, - Role currentPhase, - Team winner, - int currentRedFound, - int currentBlueFound, - int remainingGuesses, - Clue currentClue) {} From b516914809a5750876534e4a0bfbf6941cbf3828 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 00:37:05 +0200 Subject: [PATCH 46/60] test: update recovery tests for direct game state dto --- .../playingfield/GameManagerFactoryTest.java | 53 +++++++++++++++++-- .../backend/playingfield/GameManagerTest.java | 48 ++++++++++------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java index 5a4143f..0fdce57 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java @@ -40,8 +40,8 @@ void testCreate() { void testCreateFromSnapshotWithClue() { GameStateDataTransferObject snapshot = new GameStateDataTransferObject( - null, Team.RED, + Team.BLUE, Role.OPERATIVE, new ClueDto("ANIMAL", 2), 2, @@ -52,21 +52,66 @@ void testCreateFromSnapshotWithClue() { GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); assertNotNull(recovered); - assertTrue(recovered.getCardList().get(0).isGuessed()); - assertEquals(2, recovered.getRemainingGuesses()); + assertEquals(Team.BLUE, recovered.getCurrentTurn()); + assertEquals(Role.OPERATIVE, recovered.getCurrentPhase()); assertEquals("ANIMAL", recovered.getCurrentClueWord()); + assertEquals(2, recovered.getRemainingGuesses()); + 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, 0, + null, + Team.BLUE, + Role.SPYMASTER, + null, + 0, 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, + 1, + 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 644f4db..40e934c 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java @@ -14,6 +14,9 @@ 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; @@ -332,8 +335,8 @@ void testPassTurn_throwsWhenSpymaster() { @Test void recoveryConstructorThrowsWhenCardsAreNull() { - GameRecoveryState state = - new GameRecoveryState(null, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null); + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, Role.SPYMASTER, null, 0, null); assertThrows( IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); @@ -341,9 +344,8 @@ void recoveryConstructorThrowsWhenCardsAreNull() { @Test void recoveryConstructorThrowsWhenCardsAreEmpty() { - List cards = List.of(); - GameRecoveryState state = - new GameRecoveryState(cards, Team.RED, Role.SPYMASTER, null, 0, 0, 0, null); + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, Role.SPYMASTER, null, 0, List.of()); assertThrows( IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); @@ -351,9 +353,11 @@ void recoveryConstructorThrowsWhenCardsAreEmpty() { @Test void recoveryConstructorThrowsWhenCurrentTurnIsNull() { - List cards = List.of(new Card("Dog", Color.RED)); - GameRecoveryState state = - new GameRecoveryState(cards, null, Role.SPYMASTER, null, 0, 0, 0, null); + List cards = + List.of(new CardDataTransferObject("Dog", Color.RED, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, null, Role.SPYMASTER, null, 0, cards); assertThrows( IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); @@ -361,9 +365,11 @@ void recoveryConstructorThrowsWhenCurrentTurnIsNull() { @Test void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { - List cards = List.of(new Card("Dog", Color.RED)); - GameRecoveryState state = - new GameRecoveryState(cards, Team.RED, null, null, 0, 0, 0, null); + List cards = + List.of(new CardDataTransferObject("Dog", Color.RED, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, null, null, 0, cards); assertThrows( IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); @@ -371,15 +377,15 @@ void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { @Test void recoveryConstructorRestoresPersistedState() { - Card guessedRed = new Card("Dog", Color.RED); - guessedRed.setIsGuessedTrue(); - List cards = new ArrayList<>(); - cards.add(guessedRed); - cards.add(new Card("Cat", Color.BLUE)); - - GameRecoveryState state = - new GameRecoveryState( - cards, Team.BLUE, Role.OPERATIVE, Team.RED, 1, 0, 2, new Clue("ANIMAL", 2)); + 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), 2, cards); + GameManager restored = new GameManager(state, mockClueValidationService); assertEquals(Team.BLUE, restored.getCurrentTurn()); @@ -387,6 +393,8 @@ void recoveryConstructorRestoresPersistedState() { assertEquals(2, restored.getRemainingGuesses()); assertEquals("ANIMAL", restored.getCurrentClueWord()); assertEquals(Team.RED, restored.getWinner()); + assertEquals(1, restored.getCurrentRedFound()); + assertEquals(0, restored.getCurrentBlueFound()); assertTrue(restored.getCardList().get(0).isGuessed()); } } From ada50ff263bd000407a82d51586b4563bcad352d Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:35:46 +0200 Subject: [PATCH 47/60] feat: add snapshot retrieval for lobby and game state --- .../backend/lobby/services/LobbyService.java | 10 ++++++++++ .../codenames/backend/playingfield/GameService.java | 11 +++++++++++ 2 files changed, 21 insertions(+) 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 865bbee..a078e8c 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 @@ -313,4 +313,14 @@ public String getHost(String lobbyCode) { 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/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java index 4488f70..a3ec0b5 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -145,4 +145,15 @@ public boolean isGameStarted(String lobbyCode) { 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)); + } } From 7301272e4f3d5572a5af15f89800a9495072b0a8 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:36:42 +0200 Subject: [PATCH 48/60] feat: add system state persistence service --- .../SystemStatePersistenceService.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java 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 0000000..8a63067 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java @@ -0,0 +1,33 @@ +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; + +@Service +public class SystemStatePersistenceService { + + private final JsonStateStore stateStore; + private final LobbyService lobbyService; + private final GameService gameService; + + public SystemStatePersistenceService( + JsonStateStore stateStore, LobbyService lobbyService, GameService gameService) { + + this.stateStore = stateStore; + this.lobbyService = lobbyService; + this.gameService = gameService; + } + + public void persistCurrentState() { + + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + lobbyService.getLobbySnapshots(), + gameService.getGameSnapshots()); + + stateStore.save(snapshot); + } +} From 7c9bbc15641c271067cc87e518b3ac17e3bf51ee Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:37:08 +0200 Subject: [PATCH 49/60] test: add persistence service and snapshot tests --- .../lobby/services/LobbyServiceTest.java | 23 +++++++++ .../backend/playingfield/GameServiceTest.java | 23 +++++++++ .../SystemStatePersistenceServiceTest.java | 49 +++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java 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 7b8f787..60dba66 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 @@ -20,6 +20,7 @@ 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; @@ -367,4 +368,26 @@ void testRestoreLobbyAddsLobbyToLobbyList() { 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/GameServiceTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java index 6389d6b..d1e1788 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java @@ -18,6 +18,7 @@ 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; @@ -133,4 +134,26 @@ void testIsGameStartedWhenGameExists() { void testIsGameStartedWhenGameDoesNotExist() { assertFalse(gameService.isGameStarted("UNKNOWN")); } + + @Test + void getGameSnapshotsShouldReturnAllCurrentGameStates() { + GameStateDataTransferObject expected = + new GameStateDataTransferObject( + null, + redTeam, + Role.SPYMASTER, + new ClueDto("ANIMAL", 2), + 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/SystemStatePersistenceServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java new file mode 100644 index 0000000..e2d2c0f --- /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.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(); + + org.junit.jupiter.api.Assertions.assertEquals( + SystemSnapshot.CURRENT_SCHEMA_VERSION, snapshot.schemaVersion()); + org.junit.jupiter.api.Assertions.assertEquals(lobbySnapshots, snapshot.lobbies()); + org.junit.jupiter.api.Assertions.assertEquals(gameSnapshots, snapshot.games()); + } +} From 2848ef16db1b3dc8d46b6fbcff48529527e1c1d8 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:38:22 +0200 Subject: [PATCH 50/60] docs: add javadocs for system state persistence service --- .../SystemStatePersistenceService.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java index 8a63067..17bf240 100644 --- a/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java @@ -5,6 +5,12 @@ 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 { @@ -12,14 +18,27 @@ public class SystemStatePersistenceService { 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) { + 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 = From 8d4bf230ec2f01142858432a468f99bf016b76b6 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:44:29 +0200 Subject: [PATCH 51/60] refactor: persist system state after lobby and gameplay actions --- .../game/controller/GameSocketController.java | 13 ++++++++++++- .../backend/lobby/controller/LobbyController.java | 13 +++++++++++-- .../codenames/backend/websocket/GameController.java | 8 +++++++- 3 files changed, 30 insertions(+), 4 deletions(-) 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 index b9ec496..c688b7b 100644 --- a/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java +++ b/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java @@ -6,6 +6,7 @@ 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; @@ -22,6 +23,7 @@ public class GameSocketController { private final GameService gameService; private final SimpMessagingTemplate messagingTemplate; + private final SystemStatePersistenceService persistenceService; private static final String GAME_TOPIC_PREFIX = "/topic/game/"; @@ -30,11 +32,16 @@ public class 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) { + public GameSocketController( + GameService gameService, + SimpMessagingTemplate messagingTemplate, + SystemStatePersistenceService persistenceService) { this.gameService = gameService; this.messagingTemplate = messagingTemplate; + this.persistenceService = persistenceService; } /** @@ -44,6 +51,7 @@ public GameSocketController(GameService gameService, SimpMessagingTemplate messa */ @MessageMapping("/start-game") public void startGame(StartGameMessage message) { + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), @@ -61,6 +69,7 @@ public void startGame(StartGameMessage message) { public void revealCard(RevealCardMessage message) { gameService.flipCard(message.getLobbyCode(), message.getPosition(), message.getCurrentTurn()); + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), @@ -81,6 +90,7 @@ public void submitClue(ClueMessage message) { message.getLobbyCode(), new Clue(message.getWord(), message.getGuessAmount()), message.getCurrentTurn()); + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), @@ -96,6 +106,7 @@ public void submitClue(ClueMessage message) { public void passTurn(PassTurnMessage message) { gameService.passTurn(message.getLobbyCode(), message.getCurrentTurn()); + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), 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 2f73b52..2efadfa 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 @@ -3,6 +3,7 @@ import com.codenames.codenames.backend.lobby.dto.LobbyResponse; 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; @@ -25,15 +26,18 @@ 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; } /** @@ -50,6 +54,7 @@ public ResponseEntity createLobby(@RequestParam String username) return ResponseEntity.internalServerError() .body(new LobbyResponse("Error while creating lobby.", "", null, false)); } else { + persistenceService.persistCurrentState(); List players = service.getPlayersDto(lobbyCode); return ResponseEntity.ok( new LobbyResponse("Successfully created Lobby.", lobbyCode, players, false) @@ -69,6 +74,7 @@ public ResponseEntity joinLobby( @RequestParam String username, @PathVariable String lobbyCode) { boolean joined = service.joinLobby(username, lobbyCode); if (joined) { + persistenceService.persistCurrentState(); return ResponseEntity.ok( new LobbyResponse( "Joined Lobby successfully.", @@ -96,6 +102,8 @@ public ResponseEntity leaveLobby( @RequestParam String username) { boolean left = service.leaveLobby(username, lobbyCode); if (left) { + service.checkLobbyStillHasPlayers(lobbyCode); + persistenceService.persistCurrentState(); ResponseEntity response = ResponseEntity.ok( new LobbyResponse( "Left lobby successfully.", @@ -104,7 +112,6 @@ public ResponseEntity leaveLobby( false ) ); - service.checkLobbyStillHasPlayers(lobbyCode); return response; } else { return ResponseEntity.badRequest() @@ -149,6 +156,7 @@ public ResponseEntity selectPosition( ); if (updated) { + persistenceService.persistCurrentState(); return ResponseEntity.ok( new LobbyResponse( "Position selected successfully.", @@ -185,6 +193,7 @@ public ResponseEntity startGame( boolean isStarted = service.startGame(lobbyCode, username); if (isStarted) { + persistenceService.persistCurrentState(); return ResponseEntity.ok( new LobbyResponse( "Game is starting now.", 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 db30cfc..bfb791e 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java @@ -2,6 +2,7 @@ import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -23,6 +24,7 @@ public class GameController { private final GameService gameService; private final SimpMessagingTemplate messagingTemplate; private final SessionRegistry sessionRegistry; + private final SystemStatePersistenceService persistenceService; /** * Creates a new {@code GameController}. @@ -31,16 +33,19 @@ public class GameController { * @param gameService the service handling game state retrieval * @param messagingTemplate the messaging template used for broadcasting updates * @param sessionRegistry the registry managing WebSocket sessions + * @param persistenceService service used to persist current backend state */ public GameController( LobbyService lobbyService, GameService gameService, SimpMessagingTemplate messagingTemplate, - SessionRegistry sessionRegistry) { + SessionRegistry sessionRegistry, + SystemStatePersistenceService persistenceService) { this.lobbyService = lobbyService; this.gameService = gameService; this.messagingTemplate = messagingTemplate; this.sessionRegistry = sessionRegistry; + this.persistenceService = persistenceService; } /** @@ -79,6 +84,7 @@ public void join(JoinMessage message, SimpMessageHeaderAccessor headerAccessor) } sessionRegistry.register(sessionId, message.getName(), message.getCode()); + persistenceService.persistCurrentState(); sendPlayerUpdate(message.getCode()); sendGameStateUpdate(message.getCode()); From 5c23caf8a797194593ae6dc2442c7059bb6f7ef7 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:45:18 +0200 Subject: [PATCH 52/60] test: update controller tests for state persistence calls --- .../game/controller/GameSocketControllerTest.java | 11 +++++++---- .../lobby/controller/LobbyControllerTest.java | 6 +++++- .../backend/websocket/GameControllerTest.java | 13 +++++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) 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 index a5166d2..0963a11 100644 --- a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -10,6 +10,7 @@ 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; @@ -27,13 +28,14 @@ class GameSocketControllerTest { @Mock private GameService gameService; @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private SystemStatePersistenceService persistenceService; private GameSocketController controller; @BeforeEach void setUp() { - controller = new GameSocketController(gameService, messagingTemplate); + controller = new GameSocketController(gameService, messagingTemplate, persistenceService); } @Test @@ -47,6 +49,7 @@ void startGameShouldBroadcastState() { controller.startGame(message); + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @@ -64,7 +67,7 @@ void revealCardShouldBroadcastState() { controller.revealCard(message); verify(gameService).flipCard("ABCDE", 0, Team.RED); - + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @@ -83,7 +86,7 @@ void submitClueShouldBroadcastState() { controller.submitClue(message); verify(gameService).submitClue(anyString(), any(), any()); - + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @@ -100,7 +103,7 @@ void passTurnShouldBroadcastUpdatedState() { controller.passTurn(message); verify(gameService).passTurn("ABCDE", Team.RED); - + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } 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 f45a62e..38a970a 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 @@ -2,6 +2,7 @@ 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 org.junit.jupiter.api.Test; @@ -28,6 +29,9 @@ class LobbyControllerTest { @MockBean private LobbyService service; + @MockBean + private SystemStatePersistenceService persistenceService; + @Test void createLobbyShouldReturn200() throws Exception { when(service.createLobby("TestUser")).thenReturn("ABCDE"); @@ -216,4 +220,4 @@ void testStartGameReturns400_WhenServiceReturnsFalse() throws Exception{ .andExpect(jsonPath("$.isStarted") .value("false")); } -} \ No newline at end of file +} 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 192a9d7..3dbf0c2 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -11,6 +11,7 @@ import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -30,15 +31,19 @@ class GameControllerTest { private SessionRegistry sessionRegistry; private GameController controller; private SimpMessagingTemplate messagingTemplate; + private SystemStatePersistenceService persistenceService; @BeforeEach void setup() { lobbyService = mock(LobbyService.class); gameService = mock(GameService.class); messagingTemplate = mock(SimpMessagingTemplate.class); + persistenceService = mock(SystemStatePersistenceService.class); sessionRegistry = new SessionRegistry(); - controller = new GameController(lobbyService, gameService, messagingTemplate, sessionRegistry); + controller = + new GameController( + lobbyService, gameService, messagingTemplate, sessionRegistry, persistenceService); } @Test @@ -63,6 +68,7 @@ void shouldRegisterJoinAndRegisterSession() { controller.join(msg, accessor); verify(lobbyService).joinLobby("Max", "ABCDE"); + verify(persistenceService).persistCurrentState(); assertEquals("Max", sessionRegistry.getUser("123")); assertEquals("ABCDE", sessionRegistry.getLobby("123")); @@ -90,7 +96,7 @@ void shouldSendErrorMessageWhenJoinFails() { controller.join(msg, accessor); verify(messagingTemplate).convertAndSend("/topic/errors/123", "Join failed"); - + verifyNoInteractions(persistenceService); verifyNoMoreInteractions(messagingTemplate); } @@ -107,6 +113,7 @@ void shouldDoNothingWhenSessionIdIsNull() { verifyNoInteractions(lobbyService); verifyNoInteractions(messagingTemplate); + verifyNoInteractions(persistenceService); } @Test @@ -132,6 +139,7 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { assertEquals("ABCDE", sessionRegistry.getLobby("123")); verify(lobbyService).joinLobby("Max", "ABCDE"); + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } @@ -158,6 +166,7 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { assertEquals("Max", sessionRegistry.getUser("reconnect-1")); assertEquals("ABCDE", sessionRegistry.getLobby("reconnect-1")); + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } From d689b01457f70a950d0abbc29075b788e024af3f Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:46:45 +0200 Subject: [PATCH 53/60] style: fix imports --- .../lobby/controller/LobbyControllerTest.java | 199 ++++++++---------- 1 file changed, 91 insertions(+), 108 deletions(-) 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 38a970a..79e057b 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,10 +1,17 @@ package com.codenames.codenames.backend.lobby.controller; +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; @@ -12,116 +19,107 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -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; - @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; + @MockBean private SystemStatePersistenceService persistenceService; @Test void createLobbyShouldReturn200() throws Exception { when(service.createLobby("TestUser")).thenReturn("ABCDE"); - mockMvc.perform(get("/lobby/create") - .param("username", "TestUser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) - .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); } @Test void createLobbyBlankLobbyCode() throws Exception { when(service.createLobby("TestUser")).thenReturn(""); - mockMvc.perform(get("/lobby/create") - .param("username", "TestUser")) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("Error while creating lobby.")) - .andExpect(jsonPath("$.lobbyCode").value("")); + 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("")); + 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 { when(service.joinLobby("TestUser", "ABCDE")).thenReturn(true); - mockMvc.perform(get("/lobby/ABCDE/join") - .param("username", "TestUser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Joined Lobby successfully.")); + mockMvc + .perform(get("/lobby/ABCDE/join").param("username", "TestUser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Joined Lobby successfully.")); } @Test void getLobbyInfoShouldReturn200() throws Exception { - when(service.getPlayersDto("ABCDE")).thenReturn(List.of(new PlayerDto("test", null, null, true))); + when(service.getPlayersDto("ABCDE")) + .thenReturn(List.of(new PlayerDto("test", null, null, true))); String url = "/lobby/ABCDE"; - mockMvc.perform(get(url)) - .andExpect(status().isOk()); + mockMvc.perform(get(url)).andExpect(status().isOk()); } @Test void joinLobbyShouldReturn400_whenNotFound() throws Exception { when(service.joinLobby("TestUser", "XXXXX")).thenReturn(false); - mockMvc.perform(get("/lobby/XXXXX/join") - .param("username", "TestUser")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Could not find lobby.")); + 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 { when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(true); - mockMvc.perform(get("/lobby/ABCDE/leave") - .param("username", "TestUser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Left lobby successfully.")); + mockMvc + .perform(get("/lobby/ABCDE/leave").param("username", "TestUser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Left lobby successfully.")); } @Test void leaveLobbyNoSuccess() throws Exception { when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(false); - mockMvc.perform(get("/lobby/ABCDE/leave") - .param("username", "TestUser") - .param("lobbyCode", "ABCDE")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Could not find lobby.")); + mockMvc + .perform( + get("/lobby/ABCDE/leave").param("username", "TestUser").param("lobbyCode", "ABCDE")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not find lobby.")); } @Test void selectPositionShouldReturn200whenSuccess() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(true); - mockMvc.perform(post("/lobby/ABCDE/select-position") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + mockMvc + .perform( + post("/lobby/ABCDE/select-position") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ { "username": "TestUser", "team": "RED", @@ -129,19 +127,21 @@ void selectPositionShouldReturn200whenSuccess() throws Exception { "isHost": "true" } """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Position selected successfully.")) - .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Position selected successfully.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); } @Test void selectPositionShouldReturn400whenAssignmentFails() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(false); - mockMvc.perform(post("/lobby/ABCDE/select-position") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + mockMvc + .perform( + post("/lobby/ABCDE/select-position") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ { "username": "TestUser", "team": "RED", @@ -149,75 +149,58 @@ void selectPositionShouldReturn400whenAssignmentFails() throws Exception { "isHost": "true" } """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Could not assign selected team/role.")) - .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not assign selected team/role.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); } @Test void getLobbyInfoShouldReturn200_whenLobbyExists() throws Exception { - List players = List.of( - new PlayerDto("Alice", null, null, true), - new PlayerDto("Bob", null, null, false) - ); + 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")); + 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 testStartGameReturns200_WhenConditionIsMet() throws Exception { - List players = List.of( - new PlayerDto("Alice", null, null, true), - new PlayerDto("Bob", null, null, false) - ); + 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")); + 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")); } @Test - void testStartGameReturns400_WhenServiceReturnsFalse() throws Exception{ - List players = List.of( - new PlayerDto("Alice", null, null, true), - new PlayerDto("Bob", null, null, false) - ); + void testStartGameReturns400_WhenServiceReturnsFalse() 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")); + 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")); } } From a31b94e019f4a2fb12e0667015a4e35472e9ceab Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 01:51:09 +0200 Subject: [PATCH 54/60] refactor: remove redundant response variable and verify persistence service usage in lobby controller test --- .../codenames/backend/lobby/controller/LobbyController.java | 3 +-- .../backend/lobby/controller/LobbyControllerTest.java | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 2efadfa..fd108aa 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 @@ -104,7 +104,7 @@ public ResponseEntity leaveLobby( if (left) { service.checkLobbyStillHasPlayers(lobbyCode); persistenceService.persistCurrentState(); - ResponseEntity response = ResponseEntity.ok( + return ResponseEntity.ok( new LobbyResponse( "Left lobby successfully.", lobbyCode, @@ -112,7 +112,6 @@ public ResponseEntity leaveLobby( false ) ); - return response; } else { return ResponseEntity.badRequest() .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); 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 79e057b..e32e2a9 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,5 +1,6 @@ package com.codenames.codenames.backend.lobby.controller; +import static org.mockito.Mockito.verify; 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; @@ -37,6 +38,8 @@ void createLobbyShouldReturn200() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + + verify(persistenceService).persistCurrentState(); } @Test From e2b0e4628c71432502bddfd519f0a4aeb6e59ba4 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 10:59:07 +0200 Subject: [PATCH 55/60] style: fix checkstyle issues --- .../backend/lobby/services/LobbyService.java | 3 ++- .../lobby/controller/LobbyControllerTest.java | 14 +++++++------- .../backend/lobby/services/LobbyServiceTest.java | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) 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 a078e8c..91b542d 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 @@ -25,7 +25,8 @@ @Service public class LobbyService { - @Getter private final Map lobbyList = new ConcurrentHashMap<>(); + @Getter + private final Map lobbyList = new ConcurrentHashMap<>(); private final LobbyCodeGenerator generator; private final GameService gameService; private final ChatService chatService; 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 e32e2a9..47d2c1c 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 @@ -65,7 +65,7 @@ void createLobbyNullLobbyCode() throws Exception { } @Test - void joinLobbyShouldReturn200_whenSuccess() throws Exception { + void joinLobbyShouldReturn200WhenSuccess() throws Exception { when(service.joinLobby("TestUser", "ABCDE")).thenReturn(true); mockMvc @@ -83,7 +83,7 @@ void getLobbyInfoShouldReturn200() throws Exception { } @Test - void joinLobbyShouldReturn400_whenNotFound() throws Exception { + void joinLobbyShouldReturn400WhenNotFound() throws Exception { when(service.joinLobby("TestUser", "XXXXX")).thenReturn(false); mockMvc @@ -93,7 +93,7 @@ void joinLobbyShouldReturn400_whenNotFound() throws Exception { } @Test - void leaveLobbyShouldReturn200_whenSuccess() throws Exception { + void leaveLobbyShouldReturn200WhenSuccess() throws Exception { when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(true); mockMvc @@ -136,7 +136,7 @@ void selectPositionShouldReturn200whenSuccess() throws Exception { } @Test - void selectPositionShouldReturn400whenAssignmentFails() throws Exception { + void selectPositionShouldReturn400WhenAssignmentFails() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(false); mockMvc @@ -158,7 +158,7 @@ void selectPositionShouldReturn400whenAssignmentFails() throws Exception { } @Test - void getLobbyInfoShouldReturn200_whenLobbyExists() throws Exception { + void getLobbyInfoShouldReturn200WhenLobbyExists() throws Exception { List players = List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); @@ -174,7 +174,7 @@ void getLobbyInfoShouldReturn200_whenLobbyExists() throws Exception { } @Test - void testStartGameReturns200_WhenConditionIsMet() throws Exception { + void testStartGameReturns200WhenConditionIsMet() throws Exception { List players = List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); @@ -191,7 +191,7 @@ void testStartGameReturns200_WhenConditionIsMet() throws Exception { } @Test - void testStartGameReturns400_WhenServiceReturnsFalse() throws Exception { + void testStartGameReturns400WhenServiceReturnsFalse() throws Exception { List players = List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, 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 60dba66..3363a43 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 @@ -291,7 +291,7 @@ void testGetPlayersDto_lobbyNotExists() { } @Test - void getPlayersDtoShouldReturnPlayerDTOs_whenLobbyExists() { + void getPlayersDtoShouldReturnPlayerDtosWhenLobbyExists() { lobbyService.createLobby("Host"); List result = lobbyService.getPlayersDto("ABCDE"); @@ -331,7 +331,7 @@ void testGetIsStarted() { } @Test - void testGetIsStarted_GameServiceReturnsFalse() { + void testGetIsStartedGameServiceReturnsFalse() { when(gameService.isGameStarted("ABCDE")).thenReturn(false); boolean result = lobbyService.getIsStarted("ABCDE"); @@ -339,7 +339,7 @@ void testGetIsStarted_GameServiceReturnsFalse() { } @Test - void testGetHost_Works() { + void testGetHostWorks() { lobbyService.createLobby("Alice"); lobbyService.joinLobby("Bob", "ABCDE"); lobbyService.joinLobby("Caesar", "ABCDE"); @@ -353,7 +353,7 @@ void testGetHost_Works() { @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {"ABCDE"}) - void testGetHost_ReturnsEmptyString(String lobbyCode) { + void testGetHostReturnsEmptyString(String lobbyCode) { String result = lobbyService.getHost(lobbyCode); assertEquals("", result); From e4ea92e66244ad9b02f575d8c3b3539550cdd1a1 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 17:45:44 +0200 Subject: [PATCH 56/60] chore: persist backend state with docker volume --- docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6296087..44d70a2 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 From 24fabaa50123104c5190aff7d6d50edac9b81d25 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 17:51:18 +0200 Subject: [PATCH 57/60] refactor: reduce pre-game lobby persistence --- .../lobby/controller/LobbyController.java | 125 +++++++----------- 1 file changed, 47 insertions(+), 78 deletions(-) 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 fd108aa..4415ca4 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 @@ -20,7 +20,6 @@ *

Provides endpoints for creating, joining, and leaving lobbies. Delegates business logic to * {@link LobbyService}. */ - @RestController @RequestMapping("/lobby") public class LobbyController { @@ -46,75 +45,68 @@ public LobbyController(LobbyService service, SystemStatePersistenceService persi * @param username the username of the requesting user * @return a response containing the result and the generated lobby code */ - @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.", "", null, false)); + .body(new LobbyResponse("Error while creating lobby.", "", null, false)); } else { - persistenceService.persistCurrentState(); + List players = service.getPlayersDto(lobbyCode); return ResponseEntity.ok( - new LobbyResponse("Successfully created Lobby.", lobbyCode, players, false) - ); + 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 */ @GetMapping("/{lobbyCode}/join") public ResponseEntity joinLobby( - @RequestParam String username, @PathVariable String lobbyCode) { + @RequestParam String username, @PathVariable String lobbyCode) { boolean joined = service.joinLobby(username, lobbyCode); if (joined) { - persistenceService.persistCurrentState(); + return ResponseEntity.ok( - new LobbyResponse( - "Joined Lobby successfully.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + new LobbyResponse( + "Joined Lobby successfully.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } else { return ResponseEntity.badRequest() - .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); + .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 */ @GetMapping("/{lobbyCode}/leave") public ResponseEntity leaveLobby( - @PathVariable String lobbyCode, - @RequestParam String username) { + @PathVariable String lobbyCode, @RequestParam String username) { + boolean wasStarted = service.getIsStarted(lobbyCode); boolean left = service.leaveLobby(username, lobbyCode); + if (left) { service.checkLobbyStillHasPlayers(lobbyCode); - persistenceService.persistCurrentState(); + + if (wasStarted) { + persistenceService.persistCurrentState(); + } + return ResponseEntity.ok( - new LobbyResponse( - "Left lobby successfully.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + new LobbyResponse( + "Left lobby successfully.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } else { return ResponseEntity.badRequest() - .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); + .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); } } @@ -122,19 +114,15 @@ public ResponseEntity leaveLobby( * 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 + * @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 - ) { + 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) - ); + new LobbyResponse("Lobby info retrieved successfully.", lobbyCode, players, isStarted)); } /** @@ -145,34 +133,26 @@ public ResponseEntity getLobbyInfo( */ @PostMapping("/{lobbyCode}/select-position") public ResponseEntity selectPosition( - @PathVariable String lobbyCode, @RequestBody PlayerDto request - ) { - boolean updated = service.selectPosition( - request.username(), - lobbyCode, - request.team(), - request.role() - ); + @PathVariable String lobbyCode, @RequestBody PlayerDto request) { + boolean updated = + service.selectPosition(request.username(), lobbyCode, request.team(), request.role()); if (updated) { - persistenceService.persistCurrentState(); + return ResponseEntity.ok( - new LobbyResponse( - "Position selected successfully.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + new LobbyResponse( + "Position selected successfully.", + lobbyCode, + service.getPlayersDto(lobbyCode), + false)); } else { - return ResponseEntity.badRequest().body( + return ResponseEntity.badRequest() + .body( new LobbyResponse( - "Could not assign selected team/role.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + "Could not assign selected team/role.", + lobbyCode, + service.getPlayersDto(lobbyCode), + false)); } } @@ -182,33 +162,22 @@ public ResponseEntity selectPosition( * @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 + * whether the starting was successful or not */ - @GetMapping("/{lobbyCode}/start-game") public ResponseEntity startGame( - @PathVariable String lobbyCode, @RequestParam String username - ) { + @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 - ) - ); + new LobbyResponse( + "Game is starting now.", lobbyCode, service.getPlayersDto(lobbyCode), true)); } - return ResponseEntity.badRequest().body( + return ResponseEntity.badRequest() + .body( new LobbyResponse( - "Could not start the game.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + "Could not start the game.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } } From e05976ed76d187bd352e3c1127b4c916a3aeca98 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 18:15:11 +0200 Subject: [PATCH 58/60] test: align lobby and game controller persistence expectations --- .../game/controller/GameSocketController.java | 1 - .../backend/websocket/GameController.java | 2 +- .../controller/GameSocketControllerTest.java | 47 +++++++++---------- .../lobby/controller/LobbyControllerTest.java | 12 ++++- .../backend/websocket/GameControllerTest.java | 6 +-- 5 files changed, 38 insertions(+), 30 deletions(-) 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 index c688b7b..59d41e9 100644 --- a/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java +++ b/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java @@ -51,7 +51,6 @@ public GameSocketController( */ @MessageMapping("/start-game") public void startGame(StartGameMessage message) { - persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), 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 bfb791e..a208cc9 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java @@ -84,7 +84,7 @@ public void join(JoinMessage message, SimpMessageHeaderAccessor headerAccessor) } sessionRegistry.register(sessionId, message.getName(), message.getCode()); - persistenceService.persistCurrentState(); + sendPlayerUpdate(message.getCode()); sendGameStateUpdate(message.getCode()); 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 index 0963a11..1c56dd6 100644 --- a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -2,6 +2,7 @@ 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; @@ -25,63 +26,62 @@ @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 startGameShouldBroadcastState() { - + void startGameShouldBroadcastStateWithoutPersisting() { StartGameMessage message = new StartGameMessage(); + message.setLobbyCode(LOBBY_CODE); - message.setLobbyCode("ABCDE"); - - when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.startGame(message); - verify(persistenceService).persistCurrentState(); + verify(persistenceService, never()).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @Test - void revealCardShouldBroadcastState() { - + void revealCardShouldPersistAndBroadcastState() { RevealCardMessage message = new RevealCardMessage(); - - message.setLobbyCode("ABCDE"); + message.setLobbyCode(LOBBY_CODE); message.setPosition(0); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.revealCard(message); - verify(gameService).flipCard("ABCDE", 0, Team.RED); + verify(gameService).flipCard(LOBBY_CODE, 0, Team.RED); verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @Test - void submitClueShouldBroadcastState() { - + void submitClueShouldPersistAndBroadcastState() { ClueMessage message = new ClueMessage(); - - message.setLobbyCode("ABCDE"); + message.setLobbyCode(LOBBY_CODE); message.setWord("animal"); message.setGuessAmount(2); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.submitClue(message); @@ -91,18 +91,17 @@ void submitClueShouldBroadcastState() { } @Test - void passTurnShouldBroadcastUpdatedState() { - + void passTurnShouldPersistAndBroadcastUpdatedState() { PassTurnMessage message = new PassTurnMessage(); - - message.setLobbyCode("ABCDE"); + message.setLobbyCode(LOBBY_CODE); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStateDataTransferObject()); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.passTurn(message); - verify(gameService).passTurn("ABCDE", Team.RED); + verify(gameService).passTurn(LOBBY_CODE, Team.RED); verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } 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 47d2c1c..3ae261c 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,6 +1,7 @@ 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; @@ -39,7 +40,7 @@ void createLobbyShouldReturn200() throws Exception { .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); - verify(persistenceService).persistCurrentState(); + verifyNoInteractions(persistenceService); } @Test @@ -72,6 +73,8 @@ void joinLobbyShouldReturn200WhenSuccess() throws Exception { .perform(get("/lobby/ABCDE/join").param("username", "TestUser")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Joined Lobby successfully.")); + + verifyNoInteractions(persistenceService); } @Test @@ -94,12 +97,15 @@ void joinLobbyShouldReturn400WhenNotFound() throws Exception { @Test void leaveLobbyShouldReturn200WhenSuccess() throws Exception { + when(service.getIsStarted("ABCDE")).thenReturn(false); when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(true); mockMvc .perform(get("/lobby/ABCDE/leave").param("username", "TestUser")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Left lobby successfully.")); + + verifyNoInteractions(persistenceService); } @Test @@ -133,6 +139,8 @@ void selectPositionShouldReturn200whenSuccess() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("Position selected successfully.")) .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + + verifyNoInteractions(persistenceService); } @Test @@ -188,6 +196,8 @@ void testStartGameReturns200WhenConditionIsMet() throws Exception { .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) .andExpect(jsonPath("$.playerList[0].username").value("Alice")) .andExpect(jsonPath("$.isStarted").value("true")); + + verify(persistenceService).persistCurrentState(); } @Test 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 3dbf0c2..a8aad26 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -68,7 +68,7 @@ void shouldRegisterJoinAndRegisterSession() { controller.join(msg, accessor); verify(lobbyService).joinLobby("Max", "ABCDE"); - verify(persistenceService).persistCurrentState(); + verifyNoInteractions(persistenceService); assertEquals("Max", sessionRegistry.getUser("123")); assertEquals("ABCDE", sessionRegistry.getLobby("123")); @@ -139,7 +139,7 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { assertEquals("ABCDE", sessionRegistry.getLobby("123")); verify(lobbyService).joinLobby("Max", "ABCDE"); - verify(persistenceService).persistCurrentState(); + verifyNoInteractions(persistenceService); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } @@ -166,7 +166,7 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { assertEquals("Max", sessionRegistry.getUser("reconnect-1")); assertEquals("ABCDE", sessionRegistry.getLobby("reconnect-1")); - verify(persistenceService).persistCurrentState(); + verifyNoInteractions(persistenceService); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } From e2898e43f587183a4f61d0e3c08c54008dae3dba Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 18:18:55 +0200 Subject: [PATCH 59/60] refactor: remove unused persistence dependency from game controller --- .../codenames/backend/websocket/GameController.java | 7 +------ .../backend/websocket/GameControllerTest.java | 12 +----------- 2 files changed, 2 insertions(+), 17 deletions(-) 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 a208cc9..3cec2ae 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java @@ -2,7 +2,6 @@ import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; -import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; @@ -24,7 +23,6 @@ public class GameController { private final GameService gameService; private final SimpMessagingTemplate messagingTemplate; private final SessionRegistry sessionRegistry; - private final SystemStatePersistenceService persistenceService; /** * Creates a new {@code GameController}. @@ -33,19 +31,16 @@ public class GameController { * @param gameService the service handling game state retrieval * @param messagingTemplate the messaging template used for broadcasting updates * @param sessionRegistry the registry managing WebSocket sessions - * @param persistenceService service used to persist current backend state */ public GameController( LobbyService lobbyService, GameService gameService, SimpMessagingTemplate messagingTemplate, - SessionRegistry sessionRegistry, - SystemStatePersistenceService persistenceService) { + SessionRegistry sessionRegistry) { this.lobbyService = lobbyService; this.gameService = gameService; this.messagingTemplate = messagingTemplate; this.sessionRegistry = sessionRegistry; - this.persistenceService = persistenceService; } /** 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 a8aad26..e630b4f 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -11,7 +11,6 @@ import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; -import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -31,19 +30,15 @@ class GameControllerTest { private SessionRegistry sessionRegistry; private GameController controller; private SimpMessagingTemplate messagingTemplate; - private SystemStatePersistenceService persistenceService; @BeforeEach void setup() { lobbyService = mock(LobbyService.class); gameService = mock(GameService.class); messagingTemplate = mock(SimpMessagingTemplate.class); - persistenceService = mock(SystemStatePersistenceService.class); sessionRegistry = new SessionRegistry(); - controller = - new GameController( - lobbyService, gameService, messagingTemplate, sessionRegistry, persistenceService); + controller = new GameController(lobbyService, gameService, messagingTemplate, sessionRegistry); } @Test @@ -68,7 +63,6 @@ void shouldRegisterJoinAndRegisterSession() { controller.join(msg, accessor); verify(lobbyService).joinLobby("Max", "ABCDE"); - verifyNoInteractions(persistenceService); assertEquals("Max", sessionRegistry.getUser("123")); assertEquals("ABCDE", sessionRegistry.getLobby("123")); @@ -96,7 +90,6 @@ void shouldSendErrorMessageWhenJoinFails() { controller.join(msg, accessor); verify(messagingTemplate).convertAndSend("/topic/errors/123", "Join failed"); - verifyNoInteractions(persistenceService); verifyNoMoreInteractions(messagingTemplate); } @@ -113,7 +106,6 @@ void shouldDoNothingWhenSessionIdIsNull() { verifyNoInteractions(lobbyService); verifyNoInteractions(messagingTemplate); - verifyNoInteractions(persistenceService); } @Test @@ -139,7 +131,6 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { assertEquals("ABCDE", sessionRegistry.getLobby("123")); verify(lobbyService).joinLobby("Max", "ABCDE"); - verifyNoInteractions(persistenceService); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } @@ -166,7 +157,6 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { assertEquals("Max", sessionRegistry.getUser("reconnect-1")); assertEquals("ABCDE", sessionRegistry.getLobby("reconnect-1")); - verifyNoInteractions(persistenceService); verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } From 38c490dfd72ca907e1736238451a2cf591990779 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 20 May 2026 20:41:58 +0200 Subject: [PATCH 60/60] test: fix imports --- .../recovery/SystemStatePersistenceServiceTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java index e2d2c0f..c0d956d 100644 --- a/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java @@ -1,5 +1,6 @@ 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; @@ -41,9 +42,8 @@ void persistCurrentStateSavesCurrentSystemSnapshot() { SystemSnapshot snapshot = snapshotCaptor.getValue(); - org.junit.jupiter.api.Assertions.assertEquals( - SystemSnapshot.CURRENT_SCHEMA_VERSION, snapshot.schemaVersion()); - org.junit.jupiter.api.Assertions.assertEquals(lobbySnapshots, snapshot.lobbies()); - org.junit.jupiter.api.Assertions.assertEquals(gameSnapshots, snapshot.games()); + assertEquals(SystemSnapshot.CURRENT_SCHEMA_VERSION, snapshot.schemaVersion()); + assertEquals(lobbySnapshots, snapshot.lobbies()); + assertEquals(gameSnapshots, snapshot.games()); } }