From 18336ecc4408c8af4365c607dc129860f5ffceef Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:15:44 +0200 Subject: [PATCH 01/20] feat(game): GameSession bundles savable state; turn + autosave hook --- .../java/thb/jeanluc/adventure/game/Game.java | 12 ++-- .../jeanluc/adventure/game/GameContext.java | 67 ++++++++++++++----- .../jeanluc/adventure/game/GameSession.java | 36 ++++++++++ .../adventure/game/GameSessionTest.java | 52 ++++++++++++++ 4 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 2cf1058..68f5bb1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -32,9 +32,6 @@ public class Game { /** Loop flag. Flipped to false by {@link #stop()}. */ private boolean running = true; - /** Number of commands processed this session; shown in the HUD. */ - private int turn = 0; - /** * Runs the loop until {@link #stop()} is called or input is exhausted. */ @@ -55,7 +52,10 @@ public class Game { ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); } else { cmd.get().execute(ctx, parsed.args()); - turn++; + ctx.getSession().incrementTurn(); + if (ctx.getSession().getTurn() % 10 == 0) { + ctx.save(); + } } publishHud(); maybeEnd(); @@ -66,7 +66,7 @@ public class Game { private void maybeEnd() { Ending e = EndingEngine.triggered(ctx); if (e != null) { - ctx.getIo().print(EndingEngine.render(e, ctx, turn)); + ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn())); stop(); } } @@ -76,7 +76,7 @@ public class Game { ctx.getIo().setHud(new Hud( ctx.getPlayer().getCurrentRoom().getName(), ctx.getPlayer().getGold(), - turn, + ctx.getSession().getTurn(), Light.carryingLight(ctx))); MapView map = MapLayout.compute( ctx.getWorld(), diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java index a552b92..9f41d13 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java @@ -1,32 +1,63 @@ package thb.jeanluc.adventure.game; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.World; /** - * Bundle of everything a command needs to do its work: the world, the - * player, and the IO channel. Passing this single object keeps command - * signatures small and consistent. + * Bundle of everything a command needs: the active {@link GameSession} + * (world, player, flags, quests, turn) and the IO channel. Getters delegate + * to the session so existing command code keeps working unchanged. */ -@Getter -@RequiredArgsConstructor public class GameContext { - /** Loaded world (rooms, items, npcs). */ - private final World world; - - /** The player avatar. */ - private final Player player; - - /** IO channel for reading input and writing output. */ + private final GameSession session; private final GameIO io; - /** Mutable world flags. Created fresh per context; not a constructor arg. */ - private final GameState state = new GameState(); + /** Silent autosave hook, wired by the app; no-op by default (tests). */ + private Runnable saveCallback = () -> { }; - /** Runtime quest progress. Created fresh per context; not a constructor arg. */ - private final QuestLog questLog = new QuestLog(); + public GameContext(GameSession session, GameIO io) { + this.session = session; + this.io = io; + } + + /** Convenience for tests/legacy callers: wraps a fresh single-use session. */ + public GameContext(World world, Player player, GameIO io) { + this(new GameSession(world, player, "session"), io); + } + + public GameSession getSession() { + return session; + } + + public World getWorld() { + return session.getWorld(); + } + + public Player getPlayer() { + return session.getPlayer(); + } + + public GameState getState() { + return session.getState(); + } + + public QuestLog getQuestLog() { + return session.getQuestLog(); + } + + public GameIO getIo() { + return io; + } + + /** Sets the silent autosave hook (called by interval/quest autosave). */ + public void setSaveCallback(Runnable saveCallback) { + this.saveCallback = saveCallback; + } + + /** Runs the silent autosave hook. */ + public void save() { + saveCallback.run(); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java new file mode 100644 index 0000000..fda1bfb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java @@ -0,0 +1,36 @@ +package thb.jeanluc.adventure.game; + +import lombok.Getter; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.World; + +/** + * Bundle of the mutable, savable game state for one playthrough: the + * loaded {@link World}, the {@link Player}, world {@link GameState} flags, + * {@link QuestLog} progress, the turn counter, and the bound save-slot name. + * Save/load read and write exactly this object. + */ +@Getter +public class GameSession { + + private final World world; + private final Player player; + private final GameState state = new GameState(); + private final QuestLog questLog = new QuestLog(); + private final String slotName; + private int turn; + + public GameSession(World world, Player player, String slotName) { + this.world = world; + this.player = player; + this.slotName = slotName; + } + + public void setTurn(int turn) { + this.turn = turn; + } + + public void incrementTurn() { + this.turn++; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java new file mode 100644 index 0000000..9c729f0 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java @@ -0,0 +1,52 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameSessionTest { + + private GameSession session() { + Room k = new Room("k", "Kitchen", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w"); + return new GameSession(w, new Player(k, 0), "slot-a"); + } + + @Test + void exposesStateAndIncrementsTurn() { + GameSession s = session(); + assertThat(s.getSlotName()).isEqualTo("slot-a"); + assertThat(s.getTurn()).isZero(); + s.incrementTurn(); + s.incrementTurn(); + assertThat(s.getTurn()).isEqualTo(2); + s.setTurn(10); + assertThat(s.getTurn()).isEqualTo(10); + } + + @Test + void contextDelegatesToSession() { + GameSession s = session(); + GameContext ctx = new GameContext(s, new TestIO()); + assertThat(ctx.getWorld()).isSameAs(s.getWorld()); + assertThat(ctx.getPlayer()).isSameAs(s.getPlayer()); + assertThat(ctx.getState()).isSameAs(s.getState()); + assertThat(ctx.getQuestLog()).isSameAs(s.getQuestLog()); + assertThat(ctx.getSession()).isSameAs(s); + } + + @Test + void legacyConstructorBuildsFreshSession() { + Room k = new Room("k", "Kitchen", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w"); + GameContext ctx = new GameContext(w, new Player(k, 0), new TestIO()); + assertThat(ctx.getSession()).isNotNull(); + assertThat(ctx.getSession().getTurn()).isZero(); + } +} From 58352087c4b3d21112cf42286ec4d0714d7a4138 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:19:54 +0200 Subject: [PATCH 02/20] test(game): strengthen legacy-constructor delegation assertions Co-Authored-By: Claude Sonnet 4.6 --- .../test/java/thb/jeanluc/adventure/game/GameSessionTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java index 9c729f0..d493cab 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java @@ -48,5 +48,7 @@ class GameSessionTest { GameContext ctx = new GameContext(w, new Player(k, 0), new TestIO()); assertThat(ctx.getSession()).isNotNull(); assertThat(ctx.getSession().getTurn()).isZero(); + assertThat(ctx.getWorld()).isSameAs(w); + assertThat(ctx.getPlayer().getCurrentRoom()).isSameAs(k); } } From c392f80ed073eb54d53104968751e0c0ef5936fb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:21:56 +0200 Subject: [PATCH 03/20] feat: restore hooks for flags, quest progress, switch state --- .../thb/jeanluc/adventure/game/GameState.java | 7 ++++ .../thb/jeanluc/adventure/game/QuestLog.java | 9 ++++ .../adventure/model/item/SwitchableItem.java | 10 +++++ .../adventure/game/RestoreHooksTest.java | 42 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java index 135f03f..b56d006 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java @@ -1,5 +1,6 @@ package thb.jeanluc.adventure.game; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; @@ -24,4 +25,10 @@ public class GameState { public Set all() { return Collections.unmodifiableSet(flags); } + + /** Replaces all flags with the given collection (used by load). */ + public void restore(Collection newFlags) { + flags.clear(); + flags.addAll(newFlags); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java index d0d9bb0..8ba492d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java @@ -1,5 +1,6 @@ package thb.jeanluc.adventure.game; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -46,4 +47,12 @@ public class QuestLog { public Set completed() { return Collections.unmodifiableSet(completed); } + + /** Replaces all progress with the given stage map and completed set (load). */ + public void restore(Map stages, Collection completedIds) { + stageIndex.clear(); + stageIndex.putAll(stages); + completed.clear(); + completed.addAll(completedIds); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java index 8f8e1a3..f2b19d5 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java @@ -53,4 +53,14 @@ public class SwitchableItem extends Item { public boolean isOn() { return state; } + + /** + * Directly sets the on/off state without running effects or printing. + * Used only when restoring a saved game. + * + * @param state the state to restore + */ + public void setState(boolean state) { + this.state = state; + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java new file mode 100644 index 0000000..613a12c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java @@ -0,0 +1,42 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestoreHooksTest { + + @Test + void gameStateRestoreReplacesFlags() { + GameState s = new GameState(); + s.set("old"); + s.restore(List.of("a", "b")); + assertThat(s.isSet("old")).isFalse(); + assertThat(s.all()).containsExactlyInAnyOrder("a", "b"); + } + + @Test + void questLogRestoreReplacesProgress() { + QuestLog q = new QuestLog(); + q.start("stale"); + q.restore(Map.of("active", 2), Set.of("done")); + assertThat(q.isActive("stale")).isFalse(); + assertThat(q.stageIndex("active")).isEqualTo(2); + assertThat(q.isCompleted("done")).isTrue(); + } + + @Test + void switchableSetStateDoesNotRunEffects() { + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("on").offText("off").state(false).effects(List.of()) + .build(); + lamp.setState(true); + assertThat(lamp.isOn()).isTrue(); + } +} From 310ee65285af368096df98c78c934a7ff9131af6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:26:43 +0200 Subject: [PATCH 04/20] feat(io): GameIO.choose numbered-menu primitive (console default) --- .../java/thb/jeanluc/adventure/io/GameIO.java | 36 +++++++++++++++++++ .../adventure/io/ChooseDefaultTest.java | 32 +++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index 98584b0..9ca91b1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -9,6 +9,8 @@ import thb.jeanluc.adventure.io.text.Renderings; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.io.text.StyledText; +import java.util.List; + /** * Bidirectional channel between the engine and the player. Commands emit * semantic, role-styled output via {@link #print(StyledText)} (the primitive) @@ -56,4 +58,38 @@ public interface GameIO { default void showQuests(QuestView view) { print(QuestText.render(view)); } + + /** + * Presents a titled list of options and returns the 0-based index of the + * player's choice. Default (console) prints a numbered list and reads a + * number, re-prompting on invalid input. EOF / empty input returns the + * LAST option (menus place the safe/cancel option last). Frontends with + * native widgets (e.g. buttons) may override. + * + * @param title heading shown above the options + * @param options non-empty list of option labels + * @return 0-based index into {@code options} + */ + default int choose(String title, List options) { + StyledText.Builder b = StyledText.builder().heading(title).plain("\n"); + for (int i = 0; i < options.size(); i++) { + b.plain(" " + (i + 1) + ") " + options.get(i) + "\n"); + } + print(b.build()); + while (true) { + String line = readLine(); + if (line == null || line.isBlank()) { + return options.size() - 1; + } + try { + int n = Integer.parseInt(line.trim()); + if (n >= 1 && n <= options.size()) { + return n - 1; + } + } catch (NumberFormatException ignored) { + // fall through to re-prompt + } + write("Please enter a number between 1 and " + options.size() + "."); + } + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java new file mode 100644 index 0000000..d42ee6f --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java @@ -0,0 +1,32 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChooseDefaultTest { + + @Test + void returnsZeroBasedIndexOfValidChoice() { + TestIO io = new TestIO().enqueue("2"); + int i = io.choose("Menu", List.of("A", "B", "C")); + assertThat(i).isEqualTo(1); + } + + @Test + void rePromptsOnInvalidThenAccepts() { + TestIO io = new TestIO().enqueue("9").enqueue("x").enqueue("1"); + int i = io.choose("Menu", List.of("A", "B")); + assertThat(i).isZero(); + assertThat(io.allOutput()).contains("between 1 and 2"); + } + + @Test + void eofReturnsLastOption() { + TestIO io = new TestIO(); // empty queue → readLine() returns null + int i = io.choose("Menu", List.of("Play", "Quit")); + assertThat(i).isEqualTo(1); + } +} From c92c143058f6aa6e9eb0869613600f76e188b1bc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:29:55 +0200 Subject: [PATCH 05/20] feat(save): SaveData/SaveSlotInfo/SaveException model --- .../thb/jeanluc/adventure/save/SaveData.java | 29 +++++++++++++++++++ .../jeanluc/adventure/save/SaveException.java | 18 ++++++++++++ .../jeanluc/adventure/save/SaveSlotInfo.java | 6 ++++ .../jeanluc/adventure/save/SaveDataTest.java | 28 ++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java new file mode 100644 index 0000000..f6a96fe --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java @@ -0,0 +1,29 @@ +package thb.jeanluc.adventure.save; + +import java.util.List; +import java.util.Map; + +/** + * JSON-serialised snapshot of the mutable game state — a delta laid over the + * world freshly loaded from YAML. Header fields ({@code slotName}, room, + * {@code turn}, {@code savedAtEpochMillis}) double as the slot-list metadata. + */ +public record SaveData( + int schemaVersion, + String worldTitle, + String slotName, + long savedAtEpochMillis, + int turn, + String currentRoomId, + List visitedRoomIds, + int gold, + List inventoryItemIds, + List flags, + Map questStages, + List questCompleted, + Map> roomItemIds, + Map switchStates +) { + /** Current on-disk schema version. */ + public static final int CURRENT_VERSION = 1; +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java new file mode 100644 index 0000000..190d06b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java @@ -0,0 +1,18 @@ +package thb.jeanluc.adventure.save; + +/** Thrown when a save cannot be written/read; carries a player-facing message. */ +public class SaveException extends RuntimeException { + + public SaveException(String message, Throwable cause) { + super(message, cause); + } + + public SaveException(String message) { + super(message); + } + + /** Player-facing message (the constructor message is already user-friendly). */ + public String getUserMessage() { + return getMessage(); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java new file mode 100644 index 0000000..dd7e186 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java @@ -0,0 +1,6 @@ +package thb.jeanluc.adventure.save; + +/** Lightweight metadata for one save slot, shown in the Load menu. */ +public record SaveSlotInfo(String slug, String slotName, String roomId, + int turn, long savedAtEpochMillis) { +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java new file mode 100644 index 0000000..95033d3 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java @@ -0,0 +1,28 @@ +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveDataTest { + + @Test + void roundTripsThroughJackson() throws Exception { + SaveData data = new SaveData( + 1, "Haunted Manor", "slot-a", 1700000000000L, 7, + "library", List.of("kitchen", "library"), 3, + List.of("key"), List.of("power_on"), + Map.of("restore_power", 1), List.of("intro"), + Map.of("cellar", List.of("shovel")), Map.of("lamp", true)); + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString(data); + SaveData back = mapper.readValue(json, SaveData.class); + assertThat(back).isEqualTo(data); + assertThat(back.currentRoomId()).isEqualTo("library"); + assertThat(back.switchStates()).containsEntry("lamp", true); + } +} From 3be1036142fafcbbfdb283c64cf64f98ad8699b3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:34:48 +0200 Subject: [PATCH 06/20] feat(save): SaveCodec capture/apply over a fresh world; tolerate null collections Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/save/SaveCodec.java | 126 ++++++++++++++++++ .../thb/jeanluc/adventure/save/SaveData.java | 15 +++ .../jeanluc/adventure/save/SaveCodecTest.java | 105 +++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java new file mode 100644 index 0000000..3cce28b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java @@ -0,0 +1,126 @@ +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.game.QuestLog; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Converts between a live {@link GameSession} and a {@link SaveData} snapshot. + * Pure (no disk): {@link #capture} reads a session; {@link #apply} overlays a + * snapshot onto a session built from a freshly loaded world. + */ +public final class SaveCodec { + + private SaveCodec() { + } + + /** Reads the mutable state of {@code session} into a {@link SaveData}. */ + public static SaveData capture(GameSession session) { + World w = session.getWorld(); + Player p = session.getPlayer(); + + Map> roomItems = new LinkedHashMap<>(); + for (Room room : w.getRooms().values()) { + if (!room.getItems().isEmpty()) { + roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet())); + } + } + Map switches = new LinkedHashMap<>(); + for (Item item : w.getItems().values()) { + if (item instanceof SwitchableItem sw) { + switches.put(sw.getId(), sw.isOn()); + } + } + Map stages = new LinkedHashMap<>(); + QuestLog log = session.getQuestLog(); + for (String id : log.active()) { + stages.put(id, log.stageIndex(id)); + } + + return new SaveData( + SaveData.CURRENT_VERSION, + w.getTitle(), + session.getSlotName(), + System.currentTimeMillis(), + session.getTurn(), + p.getCurrentRoom().getId(), + new ArrayList<>(p.getVisitedRoomIds()), + p.getGold(), + new ArrayList<>(p.getInventory().keySet()), + new ArrayList<>(session.getState().all()), + stages, + new ArrayList<>(log.completed()), + roomItems, + switches); + } + + /** + * Overlays {@code data} onto {@code session} (whose world was just loaded + * from YAML). Throws {@link SaveException} if the save references ids that + * no longer exist in the current world. + */ + public static void apply(SaveData data, GameSession session) { + World w = session.getWorld(); + Player p = session.getPlayer(); + + // 1. Clear all item placements, then redistribute from the save. + for (Room room : w.getRooms().values()) { + room.getItems().clear(); + } + p.getInventory().clear(); + for (Map.Entry> e : data.roomItemIds().entrySet()) { + Room room = requireRoom(w, e.getKey()); + for (String itemId : e.getValue()) { + room.addItem(requireItem(w, itemId)); + } + } + for (String itemId : data.inventoryItemIds()) { + p.addItem(requireItem(w, itemId)); + } + + // 2. Switch states. + for (Map.Entry e : data.switchStates().entrySet()) { + if (requireItem(w, e.getKey()) instanceof SwitchableItem sw) { + sw.setState(e.getValue()); + } + } + + // 3. Player position + visited + gold. + p.setCurrentRoom(requireRoom(w, data.currentRoomId())); + p.getVisitedRoomIds().clear(); + p.getVisitedRoomIds().addAll(data.visitedRoomIds()); + p.setGold(data.gold()); + + // 4. Flags, quests, turn. + session.getState().restore(data.flags()); + session.getQuestLog().restore(data.questStages(), data.questCompleted()); + session.setTurn(data.turn()); + } + + private static Room requireRoom(World w, String id) { + Room r = w.getRooms().get(id); + if (r == null) { + throw new SaveException("Save refers to unknown room '" + id + + "'. The game content may have changed."); + } + return r; + } + + private static Item requireItem(World w, String id) { + Item i = w.getItems().get(id); + if (i == null) { + throw new SaveException("Save refers to unknown item '" + id + + "'. The game content may have changed."); + } + return i; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java index f6a96fe..75fa852 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java @@ -26,4 +26,19 @@ public record SaveData( ) { /** Current on-disk schema version. */ public static final int CURRENT_VERSION = 1; + + /** + * Compact canonical constructor: normalizes any null collection component + * to an empty immutable collection so a truncated/hand-edited save JSON + * degrades gracefully instead of NPE-ing in {@link SaveCodec#apply}. + */ + public SaveData { + if (visitedRoomIds == null) visitedRoomIds = List.of(); + if (inventoryItemIds == null) inventoryItemIds = List.of(); + if (flags == null) flags = List.of(); + if (questStages == null) questStages = Map.of(); + if (questCompleted == null) questCompleted = List.of(); + if (roomItemIds == null) roomItemIds = Map.of(); + if (switchStates == null) switchStates = Map.of(); + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java new file mode 100644 index 0000000..ed1b1ca --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java @@ -0,0 +1,105 @@ +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveCodecTest { + + /** Builds a fresh 2-room world: shovel in cellar, lamp (off) in kitchen. */ + private GameSession freshWorld(String slot) { + Room kitchen = new Room("kitchen", "Kitchen", "k"); + Room cellar = new Room("cellar", "Cellar", "c"); + kitchen.addExit(Direction.SOUTH, cellar); + Item shovel = PlainItem.builder().id("shovel").name("Shovel").description("d").light(false).build(); + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("on").offText("off").state(false).effects(List.of()).build(); + cellar.addItem(shovel); + kitchen.addItem(lamp); + Map items = new LinkedHashMap<>(); + items.put("shovel", shovel); + items.put("lamp", lamp); + Map rooms = new LinkedHashMap<>(); + rooms.put("kitchen", kitchen); + rooms.put("cellar", cellar); + World w = new World(rooms, items, Map.of(), "Haunted Manor", "welcome"); + return new GameSession(w, new Player(kitchen, 0), slot); + } + + @Test + void captureThenApplyToFreshWorldReproducesState() { + GameSession src = freshWorld("slot-a"); + // mutate: move to cellar, pick up shovel, light the lamp, set a flag, gold + src.getPlayer().setCurrentRoom(src.getWorld().getRooms().get("cellar")); + Item shovel = src.getWorld().getRooms().get("cellar").removeItem("shovel").orElseThrow(); + src.getPlayer().addItem(shovel); + ((SwitchableItem) src.getWorld().getItems().get("lamp")).setState(true); + src.getState().set("power_on"); + src.getPlayer().setGold(5); + src.setTurn(12); + + SaveData data = SaveCodec.capture(src); + + GameSession dst = freshWorld("slot-a"); + SaveCodec.apply(data, dst); + + assertThat(dst.getPlayer().getCurrentRoom().getId()).isEqualTo("cellar"); + assertThat(dst.getPlayer().hasItem("shovel")).isTrue(); + assertThat(dst.getWorld().getRooms().get("cellar").findItem("shovel")).isEmpty(); + assertThat(((SwitchableItem) dst.getWorld().getItems().get("lamp")).isOn()).isTrue(); + assertThat(dst.getState().isSet("power_on")).isTrue(); + assertThat(dst.getPlayer().getGold()).isEqualTo(5); + assertThat(dst.getTurn()).isEqualTo(12); + assertThat(dst.getPlayer().getVisitedRoomIds()).contains("kitchen", "cellar"); + } + + @Test + void applyRejectsUnknownRoom() { + GameSession src = freshWorld("slot-a"); + SaveData data = SaveCodec.capture(src); + SaveData broken = new SaveData( + data.schemaVersion(), data.worldTitle(), data.slotName(), + data.savedAtEpochMillis(), data.turn(), "ballroom", + data.visitedRoomIds(), data.gold(), data.inventoryItemIds(), + data.flags(), data.questStages(), data.questCompleted(), + data.roomItemIds(), data.switchStates()); + org.junit.jupiter.api.Assertions.assertThrows(SaveException.class, + () -> SaveCodec.apply(broken, freshWorld("slot-a"))); + } + + @Test + void applyToleratesMissingCollections() { + // Construct a SaveData with null collections (simulates a hand-edited / truncated save). + // The compact constructor should normalize nulls to empty collections. + SaveData data = new SaveData( + SaveData.CURRENT_VERSION, "Haunted Manor", "slot-a", + System.currentTimeMillis(), 0, + "kitchen", + null, // visitedRoomIds + 0, + null, // inventoryItemIds + null, // flags + null, // questStages + null, // questCompleted + null, // roomItemIds + null // switchStates + ); + + GameSession dst = freshWorld("slot-a"); + // Should not throw NPE; world items remain in their default positions or cleared + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> SaveCodec.apply(data, dst)); + } +} From 3c7c9d2ad4f32877159ead8feafa6f20267694c3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:42:15 +0200 Subject: [PATCH 07/20] fix(save): restore visited set before setting current room; strengthen null-tolerance test Reorder apply() so visitedRoomIds is restored before setCurrentRoom() is called, ensuring the current room is never accidentally stripped from the visited set by the subsequent clear(). Also adds a post-condition assertion to applyToleratesMissingCollections that proves null roomItemIds leaves room item tables empty after apply. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/java/thb/jeanluc/adventure/save/SaveCodec.java | 2 +- .../src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java index 3cce28b..0f27340 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java @@ -95,9 +95,9 @@ public final class SaveCodec { } // 3. Player position + visited + gold. - p.setCurrentRoom(requireRoom(w, data.currentRoomId())); p.getVisitedRoomIds().clear(); p.getVisitedRoomIds().addAll(data.visitedRoomIds()); + p.setCurrentRoom(requireRoom(w, data.currentRoomId())); p.setGold(data.gold()); // 4. Flags, quests, turn. diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java index ed1b1ca..569589e 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java @@ -101,5 +101,7 @@ class SaveCodecTest { GameSession dst = freshWorld("slot-a"); // Should not throw NPE; world items remain in their default positions or cleared org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> SaveCodec.apply(data, dst)); + // Post-condition: applying a save with null/empty roomItemIds clears all room item tables. + assertThat(dst.getWorld().getRooms().get("kitchen").findItem("lamp")).isEmpty(); } } From 6a68d7e2f5c83f36a65db410268a2ddea21a0a9a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:44:32 +0200 Subject: [PATCH 08/20] feat(save): SaveService disk persistence (atomic write, list, load) --- .../jeanluc/adventure/save/SaveService.java | 104 ++++++++++++++++++ .../adventure/save/SaveServiceTest.java | 63 +++++++++++ 2 files changed, 167 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java new file mode 100644 index 0000000..b4c5b9e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -0,0 +1,104 @@ +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.loader.WorldLoader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Reads and writes save slots as JSON files in a directory (default + * {@code ./saves}). One slot = one {@code .json}. Loading reloads the + * world from YAML and overlays the snapshot via {@link SaveCodec}. + */ +@Slf4j +public class SaveService { + + private final Path dir; + private final ObjectMapper mapper = new ObjectMapper(); + + /** Uses the default {@code ./saves} directory. */ + public SaveService() { + this(Path.of("saves")); + } + + public SaveService(Path dir) { + this.dir = dir; + } + + /** Slug used for the on-disk filename of a slot name. */ + public static String slug(String slotName) { + String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_"); + return s.isBlank() ? "save" : s; + } + + /** Writes the session to {@code .json} atomically. */ + public void save(GameSession session) { + SaveData data = SaveCodec.capture(session); + try { + Files.createDirectories(dir); + Path target = dir.resolve(slug(session.getSlotName()) + ".json"); + Path tmp = dir.resolve(slug(session.getSlotName()) + ".json.tmp"); + mapper.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), data); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicFailed) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e); + } + } + + /** Lists all readable slots, newest first; skips unreadable files. */ + public List list() { + if (!Files.isDirectory(dir)) { + return List.of(); + } + List out = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) { + try { + SaveData d = mapper.readValue(f.toFile(), SaveData.class); + String slug = f.getFileName().toString().replaceFirst("\\.json$", ""); + out.add(new SaveSlotInfo(slug, d.slotName(), d.currentRoomId(), + d.turn(), d.savedAtEpochMillis())); + } catch (IOException badFile) { + log.warn("Skipping unreadable save file {}", f, badFile); + } + } + } catch (IOException e) { + log.warn("Could not list save directory {}", dir, e); + } + out.sort(Comparator.comparingLong(SaveSlotInfo::savedAtEpochMillis).reversed()); + return out; + } + + /** Loads a slot by slug: read JSON, reload world, overlay snapshot. */ + public GameSession load(String slug) { + Path file = dir.resolve(slug + ".json"); + SaveData data; + try { + data = mapper.readValue(file.toFile(), SaveData.class); + } catch (IOException e) { + throw new SaveException("Could not read save '" + slug + "': " + e.getMessage(), e); + } + if (data.schemaVersion() != SaveData.CURRENT_VERSION) { + throw new SaveException("Save '" + slug + "' uses an incompatible version (" + + data.schemaVersion() + "); expected " + SaveData.CURRENT_VERSION + "."); + } + WorldLoader.LoadResult fresh = new WorldLoader().load(); + GameSession session = new GameSession(fresh.world(), fresh.player(), data.slotName()); + SaveCodec.apply(data, session); + return session; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java new file mode 100644 index 0000000..9dcf54e --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -0,0 +1,63 @@ +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.loader.WorldLoader; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SaveServiceTest { + + private GameSession freshSession(String slot) { + WorldLoader.LoadResult r = new WorldLoader().load(); + return new GameSession(r.world(), r.player(), slot); + } + + @Test + void saveThenLoadReproducesState(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("my-game"); + s.getState().set("power_on"); + s.getPlayer().setGold(4); + s.setTurn(9); + svc.save(s); + + GameSession loaded = svc.load("my-game"); + assertThat(loaded.getState().isSet("power_on")).isTrue(); + assertThat(loaded.getPlayer().getGold()).isEqualTo(4); + assertThat(loaded.getTurn()).isEqualTo(9); + assertThat(loaded.getSlotName()).isEqualTo("my-game"); + } + + @Test + void listReturnsSlotMetadata(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("Alpha Run"); + s.setTurn(3); + svc.save(s); + + List slots = svc.list(); + assertThat(slots).hasSize(1); + assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run"); + assertThat(slots.getFirst().turn()).isEqualTo(3); + } + + @Test + void listSkipsCorruptFiles(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("broken.json"), "{ not json"); + assertThat(new SaveService(dir).list()).isEmpty(); + } + + @Test + void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("bad.json"), "{ not json"); + SaveService svc = new SaveService(dir); + assertThrows(SaveException.class, () -> svc.load("bad")); + } +} From ed414184340875d908193f8f890a5685e7277711 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:50:10 +0200 Subject: [PATCH 09/20] test(save): cover version mismatch + slug; clean up tmp on move failure Add loadRejectsIncompatibleVersion test asserting SaveException on schema version 999, assert derived slug in listReturnsSlotMetadata, and harden save() to delete the orphaned .tmp file when the non-atomic fallback move also fails before re-throwing. Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/save/SaveService.java | 7 ++++++- .../thb/jeanluc/adventure/save/SaveServiceTest.java | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java index b4c5b9e..b5cf690 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -52,7 +52,12 @@ public class SaveService { try { Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (IOException atomicFailed) { - Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException fallbackFailed) { + Files.deleteIfExists(tmp); + throw fallbackFailed; + } } } catch (IOException e) { throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java index 9dcf54e..e321a87 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -45,6 +45,7 @@ class SaveServiceTest { List slots = svc.list(); assertThat(slots).hasSize(1); assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run"); + assertThat(slots.getFirst().slug()).isEqualTo("alpha_run"); assertThat(slots.getFirst().turn()).isEqualTo(3); } @@ -60,4 +61,16 @@ class SaveServiceTest { SaveService svc = new SaveService(dir); assertThrows(SaveException.class, () -> svc.load("bad")); } + + @Test + void loadRejectsIncompatibleVersion(@TempDir Path dir) throws Exception { + // a syntactically valid SaveData JSON but with a future schemaVersion + String json = "{\"schemaVersion\":999,\"worldTitle\":\"X\",\"slotName\":\"v\"," + + "\"savedAtEpochMillis\":0,\"turn\":0,\"currentRoomId\":\"k\"," + + "\"visitedRoomIds\":[],\"gold\":0,\"inventoryItemIds\":[],\"flags\":[]," + + "\"questStages\":{},\"questCompleted\":[],\"roomItemIds\":{},\"switchStates\":{}}"; + Files.writeString(dir.resolve("v.json"), json); + SaveService svc = new SaveService(dir); + assertThrows(SaveException.class, () -> svc.load("v")); + } } From 206fc758a52e8385c6e41e2cf33c9c203090dc90 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:52:28 +0200 Subject: [PATCH 10/20] feat(settings): Settings + SettingsStore; runtime-togglable ConsoleIO --- .../thb/jeanluc/adventure/io/ConsoleIO.java | 16 ++++-- .../thb/jeanluc/adventure/save/Settings.java | 11 +++++ .../jeanluc/adventure/save/SettingsStore.java | 49 +++++++++++++++++++ .../adventure/io/ConsoleIOSettingsTest.java | 23 +++++++++ .../adventure/save/SettingsStoreTest.java | 27 ++++++++++ 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java index 8be6545..aaf7c26 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -32,8 +32,8 @@ public class ConsoleIO implements GameIO { private final BufferedReader in; private final PrintStream out; - private final boolean useColor; - private final GlyphMode glyphs; + private boolean useColor; + private GlyphMode glyphs; public ConsoleIO() { this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), @@ -49,13 +49,23 @@ public class ConsoleIO implements GameIO { this.in = in; this.out = out; this.glyphs = glyphs; - this.useColor = switch (colorMode) { + setColorMode(colorMode); + } + + /** Re-resolves colour emission from the given mode (AUTO checks the console). */ + public void setColorMode(ColorMode mode) { + this.useColor = switch (mode) { case ON -> true; case OFF -> false; case AUTO -> System.console() != null; }; } + /** Switches the frame/glyph fidelity tier live. */ + public void setGlyphMode(GlyphMode mode) { + this.glyphs = mode; + } + @Override public String readLine() { out.print("> "); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java new file mode 100644 index 0000000..cebac84 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java @@ -0,0 +1,11 @@ +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.io.ConsoleIO; + +/** Persisted user preferences (minimal: colour + glyph fidelity). */ +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + + public static Settings defaults() { + return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java new file mode 100644 index 0000000..c912041 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Loads/saves {@link Settings} as JSON (default {@code saves/settings.json}). */ +@Slf4j +public class SettingsStore { + + private final Path file; + private final ObjectMapper mapper = new ObjectMapper(); + + public SettingsStore() { + this(Path.of("saves", "settings.json")); + } + + public SettingsStore(Path file) { + this.file = file; + } + + /** Returns persisted settings, or {@link Settings#defaults()} on any problem. */ + public Settings load() { + if (!Files.isRegularFile(file)) { + return Settings.defaults(); + } + try { + return mapper.readValue(file.toFile(), Settings.class); + } catch (IOException e) { + log.warn("Could not read settings {}, using defaults", file, e); + return Settings.defaults(); + } + } + + /** Persists settings; logs and swallows failures (non-fatal). */ + public void save(Settings settings) { + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), settings); + } catch (IOException e) { + log.warn("Could not write settings {}", file, e); + } + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java new file mode 100644 index 0000000..927ddbb --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java @@ -0,0 +1,23 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.StringReader; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsoleIOSettingsTest { + + @Test + void colorSetterTogglesAnsiEmission() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), + new PrintStream(out), ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + io.setColorMode(ConsoleIO.ColorMode.ON); + io.print(thb.jeanluc.adventure.io.text.StyledText.builder().heading("Hi").build()); + assertThat(out.toString()).contains("["); // ANSI escape present + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java new file mode 100644 index 0000000..8b959d0 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.io.ConsoleIO; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SettingsStoreTest { + + @Test + void savesAndLoads(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + store.save(new Settings(ConsoleIO.ColorMode.ON, ConsoleIO.GlyphMode.ASCII)); + Settings loaded = store.load(); + assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON); + assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + } + + @Test + void missingFileReturnsDefaults(@TempDir Path dir) { + Settings loaded = new SettingsStore(dir.resolve("nope.json")).load(); + assertThat(loaded).isEqualTo(Settings.defaults()); + } +} From d8479285870d1fdfc96911eb4ed0af4795612a89 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:56:14 +0200 Subject: [PATCH 11/20] test(settings): cover corrupt settings file falls back to defaults Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/save/SettingsStoreTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java index 8b959d0..9cca9fd 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import thb.jeanluc.adventure.io.ConsoleIO; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -24,4 +26,12 @@ class SettingsStoreTest { Settings loaded = new SettingsStore(dir.resolve("nope.json")).load(); assertThat(loaded).isEqualTo(Settings.defaults()); } + + @Test + void corruptFileReturnsDefaults(@TempDir Path dir) throws IOException { + Path file = dir.resolve("settings.json"); + Files.writeString(file, "{ not json"); + Settings loaded = new SettingsStore(file).load(); + assertThat(loaded).isEqualTo(Settings.defaults()); + } } From 687e88e20a19b0f0a22b9a79bcd193f17fc5a526 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:58:41 +0200 Subject: [PATCH 12/20] feat(menu): MainMenu, SettingsMenu, MenuAction (frontend-agnostic) --- .../thb/jeanluc/adventure/menu/MainMenu.java | 49 ++++++++++++++ .../jeanluc/adventure/menu/MenuAction.java | 6 ++ .../jeanluc/adventure/menu/SettingsMenu.java | 67 +++++++++++++++++++ .../thb/jeanluc/adventure/menu/MenuTest.java | 49 ++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java new file mode 100644 index 0000000..70f9821 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; + +import java.util.ArrayList; +import java.util.List; + +/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */ +public final class MainMenu { + + private MainMenu() { + } + + /** Shows the main menu and returns the chosen action. */ + public static MenuAction show(GameIO io) { + int i = io.choose("HAUNTED MANOR", + List.of("New Game", "Load Game", "Settings", "Quit")); + return MenuAction.values()[i]; + } + + /** Prompts for a new save name; blank input yields {@code defaultName}. */ + public static String promptName(GameIO io, String defaultName) { + io.write("Name your save (Enter for \"" + defaultName + "\"):"); + String line = io.readLine(); + return (line == null || line.isBlank()) ? defaultName : line.trim(); + } + + /** + * Lets the player pick a save slot to load. Returns the chosen + * {@link SaveSlotInfo}, or {@code null} if there are none or the player + * picks "Back". + */ + public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) { + List slots = saves.list(); + if (slots.isEmpty()) { + io.write("No saved games."); + return null; + } + List labels = new ArrayList<>(); + for (SaveSlotInfo s : slots) { + labels.add(s.slotName() + " (" + s.roomId() + ", turn " + s.turn() + ")"); + } + labels.add("Back"); + int i = io.choose("LOAD GAME", labels); + return i < slots.size() ? slots.get(i) : null; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java new file mode 100644 index 0000000..9af99cf --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java @@ -0,0 +1,6 @@ +package thb.jeanluc.adventure.menu; + +/** Top-level main-menu choices, in display order. */ +public enum MenuAction { + NEW_GAME, LOAD, SETTINGS, QUIT +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java new file mode 100644 index 0000000..3661505 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java @@ -0,0 +1,67 @@ +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; + +import java.util.List; + +/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */ +public final class SettingsMenu { + + private SettingsMenu() { + } + + /** + * Loops the settings screen until the player picks "Back", persisting and + * applying each change. Returns the final settings. + * + * @param io active IO + * @param current starting settings + * @param store where to persist + * @param consoleIo the console IO to apply changes to live, or null if not console + */ + public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) { + Settings s = current; + while (true) { + int i = io.choose("SETTINGS", List.of( + "Colour: " + s.colorMode(), + "Glyphs: " + s.glyphMode(), + "Back")); + if (i == 0) { + s = new Settings(nextColor(s.colorMode()), s.glyphMode()); + } else if (i == 1) { + s = new Settings(s.colorMode(), nextGlyph(s.glyphMode())); + } else { + return s; + } + store.save(s); + apply(s, consoleIo); + } + } + + /** Applies colour/glyph settings to the console IO (no-op if null). */ + public static void apply(Settings s, ConsoleIO consoleIo) { + if (consoleIo != null) { + consoleIo.setColorMode(s.colorMode()); + consoleIo.setGlyphMode(s.glyphMode()); + } + } + + private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) { + return switch (m) { + case AUTO -> ConsoleIO.ColorMode.ON; + case ON -> ConsoleIO.ColorMode.OFF; + case OFF -> ConsoleIO.ColorMode.AUTO; + }; + } + + private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) { + return switch (m) { + case UNICODE -> ConsoleIO.GlyphMode.ASCII; + case ASCII -> ConsoleIO.GlyphMode.GLYPH; + case GLYPH -> ConsoleIO.GlyphMode.UNICODE; + }; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java new file mode 100644 index 0000000..40419cf --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.menu; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; +import thb.jeanluc.adventure.io.ConsoleIO; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MenuTest { + + @Test + void mainMenuMapsChoiceToAction() { + // options order: New Game(1) / Load(2) / Settings(3) / Quit(4) + assertThat(MainMenu.show(new TestIO().enqueue("1"))).isEqualTo(MenuAction.NEW_GAME); + assertThat(MainMenu.show(new TestIO().enqueue("2"))).isEqualTo(MenuAction.LOAD); + assertThat(MainMenu.show(new TestIO().enqueue("3"))).isEqualTo(MenuAction.SETTINGS); + assertThat(MainMenu.show(new TestIO().enqueue("4"))).isEqualTo(MenuAction.QUIT); + } + + @Test + void promptNameUsesDefaultOnBlank() { + assertThat(MainMenu.promptName(new TestIO().enqueue(""), "Manor")).isEqualTo("Manor"); + assertThat(MainMenu.promptName(new TestIO().enqueue("Spooky"), "Manor")).isEqualTo("Spooky"); + } + + @Test + void pickSlotReturnsNullWhenEmpty(@TempDir Path dir) { + TestIO io = new TestIO(); + assertThat(MainMenu.pickSlot(io, new SaveService(dir))).isNull(); + assertThat(io.allOutput()).contains("No saved games"); + } + + @Test + void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + // choose "Glyph mode" (assume option 2) → its toggle, then "Back" (last) + TestIO io = new TestIO().enqueue("2").enqueue("3"); + Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO()); + // UNICODE default toggles to ASCII; persisted + assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + } +} From e4238e45533fd329a5358a1f5efbb5358e4fce18 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:03:18 +0200 Subject: [PATCH 13/20] test(menu): cover pickSlot selection and Back sentinel Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/menu/MenuTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java index 40419cf..3088e3b 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java @@ -2,8 +2,11 @@ package thb.jeanluc.adventure.menu; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.game.GameSession; import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; import thb.jeanluc.adventure.save.Settings; import thb.jeanluc.adventure.save.SettingsStore; import thb.jeanluc.adventure.io.ConsoleIO; @@ -36,6 +39,28 @@ class MenuTest { assertThat(io.allOutput()).contains("No saved games"); } + private void saveSlot(Path dir, String name) { + var r = new WorldLoader().load(); + new SaveService(dir).save(new GameSession(r.world(), r.player(), name)); + } + + @Test + void pickSlotReturnsChosenSlot(@TempDir Path dir) { + saveSlot(dir, "Alpha"); + TestIO io = new TestIO().enqueue("1"); + SaveSlotInfo result = MainMenu.pickSlot(io, new SaveService(dir)); + assertThat(result).isNotNull(); + assertThat(result.slotName()).isEqualTo("Alpha"); + } + + @Test + void pickSlotReturnsNullOnBack(@TempDir Path dir) { + saveSlot(dir, "Alpha"); + // one slot → options are [1: Alpha, 2: Back] + TestIO io = new TestIO().enqueue("2"); + assertThat(MainMenu.pickSlot(io, new SaveService(dir))).isNull(); + } + @Test void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) { SettingsStore store = new SettingsStore(dir.resolve("settings.json")); From 0f05680170af2275912825441a8a7abd0c018898 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:05:27 +0200 Subject: [PATCH 14/20] feat(command): save + menu commands (QuitCommand removal deferred to Task 11) --- .../adventure/command/impl/MenuCommand.java | 44 +++++++++++++++++ .../adventure/command/impl/SaveCommand.java | 31 ++++++++++++ .../command/SaveMenuCommandTest.java | 49 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java new file mode 100644 index 0000000..ee6145e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java @@ -0,0 +1,44 @@ +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; + +import java.util.List; + +/** + * Saves the active slot and returns to the main menu by stopping the loop. + * Usage: {@code menu} (aliases {@code quit}, {@code exit}). + */ +@RequiredArgsConstructor +public class MenuCommand implements Command { + + private final SaveService saves; + private Game game; + + /** Binds the running game (two-phase wiring, as the registry is built first). */ + public void bind(Game game) { + this.game = game; + } + + @Override + public void execute(GameContext ctx, List args) { + try { + saves.save(ctx.getSession()); + ctx.getIo().write("Game saved. Returning to the main menu."); + } catch (SaveException e) { + ctx.getIo().write("Could not save: " + e.getUserMessage()); + } + if (game != null) { + game.stop(); + } + } + + @Override + public String help() { + return "menu - save and return to the main menu (aliases: quit, exit)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java new file mode 100644 index 0000000..54ce7fd --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java @@ -0,0 +1,31 @@ +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; + +import java.util.List; + +/** Manually saves the active slot. Usage: {@code save}. */ +@RequiredArgsConstructor +public class SaveCommand implements Command { + + private final SaveService saves; + + @Override + public void execute(GameContext ctx, List args) { + try { + saves.save(ctx.getSession()); + ctx.getIo().write("Game saved."); + } catch (SaveException e) { + ctx.getIo().write(e.getUserMessage()); + } + } + + @Override + public String help() { + return "save - save your game to the current slot"; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java new file mode 100644 index 0000000..dbcd07b --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.command; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.command.impl.MenuCommand; +import thb.jeanluc.adventure.command.impl.SaveCommand; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.loader.WorldLoader; +import thb.jeanluc.adventure.save.SaveService; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveMenuCommandTest { + + private GameContext ctx(String slot) { + WorldLoader.LoadResult r = new WorldLoader().load(); + return new GameContext(new GameSession(r.world(), r.player(), slot), new TestIO()); + } + + @Test + void saveCommandWritesSlotAndReportsSuccess(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameContext ctx = ctx("game-a"); + new SaveCommand(svc).execute(ctx, List.of()); + assertThat(svc.list()).extracting(s -> s.slotName()).containsExactly("game-a"); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Game saved"); + } + + @Test + void menuCommandSavesThenStopsLoop(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameContext ctx = ctx("game-b"); + Game game = new Game(ctx, new CommandRegistry(), new CommandParser()); + MenuCommand menu = new MenuCommand(svc); + menu.bind(game); + menu.execute(ctx, List.of()); + assertThat(svc.list()).hasSize(1); + // loop is stopped: run() must return without consuming queued input + ((TestIO) ctx.getIo()).enqueue("look"); + game.run(); + assertThat(ctx.getIo().readLine()).isEqualTo("look"); // sentinel still unread + } +} From 084b39e50112b7c3a1c5e39c957c2ee74bd54464 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:12:18 +0200 Subject: [PATCH 15/20] feat(game): autosave on quest completion --- .../jeanluc/adventure/game/QuestEngine.java | 1 + .../adventure/game/QuestAutosaveTest.java | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java index 7054116..46fc4cc 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java @@ -62,6 +62,7 @@ public final class QuestEngine { ctx.getQuestLog().complete(q.id()); ctx.getIo().print(StyledText.builder() .heading("★ Quest complete: ").plain(q.title()).build()); + ctx.save(); } public static QuestView viewOf(GameContext ctx) { diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java new file mode 100644 index 0000000..d3a94e2 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java @@ -0,0 +1,37 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Quest; +import thb.jeanluc.adventure.model.QuestStage; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuestAutosaveTest { + + @Test + void questCompletionTriggersSaveCallback() { + Quest q = new Quest("q", "Quest", true, List.of( + new QuestStage("Do it", List.of(new Condition(Condition.Type.FLAG, "a")), List.of())), + List.of()); + Room k = new Room("k", "K", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w", Map.of("q", q)); + GameContext ctx = new GameContext(new GameSession(w, new Player(k, 0), "slot"), new TestIO()); + AtomicInteger saves = new AtomicInteger(); + ctx.setSaveCallback(saves::incrementAndGet); + + QuestEngine.tick(ctx); // starts quest; not complete yet + assertThat(saves.get()).isZero(); + ctx.getState().set("a"); + QuestEngine.tick(ctx); // completes quest → autosave + assertThat(saves.get()).isEqualTo(1); + } +} From 4604952f633d558fd8c1cc4000706ae15bf7ff49 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:17:46 +0200 Subject: [PATCH 16/20] feat(app): main-menu shell loop wiring; gitignore saves/ Replace App.run with a main-menu shell (New Game / Load / Settings / Quit) above the game loop, wire SaveCommand + MenuCommand into the registry with an autosave callback, and remove the now-unused QuitCommand. Migrate GameTest from QuitCommand to MenuCommand. Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/.gitignore | 3 + .../main/java/thb/jeanluc/adventure/App.java | 96 ++++++++++++++----- .../adventure/command/impl/QuitCommand.java | 41 -------- .../thb/jeanluc/adventure/game/GameTest.java | 14 ++- 4 files changed, 84 insertions(+), 70 deletions(-) delete mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore index b55fdef..90926aa 100644 --- a/Semesterprojekt/.gitignore +++ b/Semesterprojekt/.gitignore @@ -40,3 +40,6 @@ build/ ### Brainstorming visual companion ### .superpowers/ + +# Local save games and settings +saves/ diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index c609650..e20b232 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -11,23 +11,36 @@ import thb.jeanluc.adventure.command.impl.HelpCommand; import thb.jeanluc.adventure.command.impl.InventoryCommand; import thb.jeanluc.adventure.command.impl.LookCommand; import thb.jeanluc.adventure.command.impl.MapCommand; +import thb.jeanluc.adventure.command.impl.MenuCommand; import thb.jeanluc.adventure.command.impl.QuestsCommand; -import thb.jeanluc.adventure.command.impl.QuitCommand; import thb.jeanluc.adventure.command.impl.ReadCommand; +import thb.jeanluc.adventure.command.impl.SaveCommand; import thb.jeanluc.adventure.command.impl.TakeCommand; import thb.jeanluc.adventure.command.impl.TalkCommand; import thb.jeanluc.adventure.command.impl.UseCommand; import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; import thb.jeanluc.adventure.loader.WorldLoader; +import thb.jeanluc.adventure.menu.MainMenu; +import thb.jeanluc.adventure.menu.MenuAction; +import thb.jeanluc.adventure.menu.SettingsMenu; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; + +import java.util.List; /** - * Entry point for the console version of the game. Loads the world from - * YAML, wires up the command registry, and hands control to the - * {@link Game} loop. + * Console entry point. Loads settings, then runs the main-menu shell loop: + * New Game / Load / Settings / Quit. Each game runs the shared {@link Game} + * loop and returns here when the player saves-and-exits, reaches an ending, + * or input ends. Shared by console and GUI via {@link #run(GameIO)}. */ @Slf4j public final class App { @@ -35,31 +48,63 @@ public final class App { private App() { } - /** - * Standard JVM entry point. - * - * @param args ignored - */ public static void main(String[] args) { GameIO io = new ConsoleIO(); try { run(io); } catch (RuntimeException e) { - log.error("Fatal error during game startup", e); + log.error("Fatal error", e); io.write("Fatal error: " + e.getMessage()); System.exit(1); } } - /** - * Boots the game on the given IO channel. Reusable by both - * {@link App} (console) and {@code AppGui} (Swing worker thread). - * - * @param io the IO channel to use - */ + /** Runs the menu shell on the given IO until the player quits. */ public static void run(GameIO io) { + SaveService saves = new SaveService(); + SettingsStore settingsStore = new SettingsStore(); + Settings settings = settingsStore.load(); + ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null; + SettingsMenu.apply(settings, consoleIo); + + boolean running = true; + while (running) { + switch (MainMenu.show(io)) { + case NEW_GAME -> play(io, saves, newSession(io, saves)); + case LOAD -> { + SaveSlotInfo slot = MainMenu.pickSlot(io, saves); + if (slot != null) { + try { + play(io, saves, saves.load(slot.slug())); + } catch (SaveException e) { + io.write("Could not load: " + e.getUserMessage()); + } + } + } + case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo); + case QUIT -> running = false; + } + } + io.write("Farewell."); + } + + /** Loads a fresh world and binds it to a newly named session. */ + private static GameSession newSession(GameIO io, SaveService saves) { WorldLoader.LoadResult loaded = new WorldLoader().load(); - GameContext ctx = new GameContext(loaded.world(), loaded.player(), io); + String name = MainMenu.promptName(io, "Manor"); + return new GameSession(loaded.world(), loaded.player(), name); + } + + /** Builds the registry, runs one game to completion, autosaving to its slot. */ + private static void play(GameIO io, SaveService saves, GameSession session) { + GameContext ctx = new GameContext(session, io); + ctx.setSaveCallback(() -> { + try { + saves.save(session); + } catch (SaveException e) { + log.warn("Autosave failed", e); + } + }); CommandRegistry registry = new CommandRegistry(); registry.register(new GoCommand(), "go", "move", "walk"); @@ -74,17 +119,18 @@ public final class App { registry.register(new QuestsCommand(), "quests", "log", "journal"); registry.register(new TalkCommand(), "talk", "speak"); registry.register(new GiveCommand(), "give"); - registry.register(new HelpCommand(registry), "help", "?"); - - QuitCommand quit = new QuitCommand(); - registry.register(quit, "quit", "exit"); + registry.register(new SaveCommand(saves), "save"); Game game = new Game(ctx, registry, new CommandParser()); - quit.bind(game); - io.print(Banner.welcome(loaded.world().getTitle())); - io.write(loaded.world().getWelcomeMessage()); - new LookCommand().execute(ctx, java.util.List.of()); + MenuCommand menu = new MenuCommand(saves); + menu.bind(game); + registry.register(menu, "menu", "quit", "exit"); + registry.register(new HelpCommand(registry), "help", "?"); + + io.print(Banner.welcome(session.getWorld().getTitle())); + io.write(session.getWorld().getWelcomeMessage()); + new LookCommand().execute(ctx, List.of()); game.run(); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java deleted file mode 100644 index 7282498..0000000 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java +++ /dev/null @@ -1,41 +0,0 @@ -package thb.jeanluc.adventure.command.impl; - -import thb.jeanluc.adventure.command.Command; -import thb.jeanluc.adventure.game.Game; -import thb.jeanluc.adventure.game.GameContext; - -import java.util.List; - -/** - * Ends the game loop. Holds a reference to the {@link Game} so it can - * flip the loop flag. Usage: {@code quit}. - */ -public class QuitCommand implements Command { - - /** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */ - private Game game; - - /** - * Binds the game instance after construction. Two-phase wiring is - * necessary because the registry is typically built before the game - * starts running. - * - * @param game the running game; must not be null - */ - public void bind(Game game) { - this.game = game; - } - - @Override - public void execute(GameContext ctx, List args) { - ctx.getIo().write("Goodbye."); - if (game != null) { - game.stop(); - } - } - - @Override - public String help() { - return "quit - exit the game (alias: exit)"; - } -} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java index 4b85ced..8b8b91d 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java @@ -1,17 +1,20 @@ package thb.jeanluc.adventure.game; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import thb.jeanluc.adventure.command.CommandParser; import thb.jeanluc.adventure.command.CommandRegistry; import thb.jeanluc.adventure.command.impl.GoCommand; import thb.jeanluc.adventure.command.impl.LookCommand; -import thb.jeanluc.adventure.command.impl.QuitCommand; +import thb.jeanluc.adventure.command.impl.MenuCommand; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.save.SaveService; +import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; @@ -19,6 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat; class GameTest { + @TempDir + Path savesDir; + @Test void run_dispatchesCommands_andQuitsCleanly() { Room kitchen = new Room("kitchen", "Kitchen", "kd"); @@ -36,7 +42,7 @@ class GameTest { CommandRegistry registry = new CommandRegistry(); registry.register(new GoCommand(), "go"); registry.register(new LookCommand(), "look"); - QuitCommand quit = new QuitCommand(); + MenuCommand quit = new MenuCommand(new SaveService(savesDir)); registry.register(quit, "quit"); Game game = new Game(ctx, registry, new CommandParser()); @@ -44,7 +50,7 @@ class GameTest { game.run(); assertThat(player.getCurrentRoom()).isEqualTo(hallway); - assertThat(io.allOutput()).contains("Goodbye"); + assertThat(io.allOutput()).contains("Returning to the main menu"); } @Test @@ -55,7 +61,7 @@ class GameTest { GameContext ctx = new GameContext(new World(Map.of("r", r), Map.of(), Map.of(), "t", "w"), player, io); CommandRegistry registry = new CommandRegistry(); - QuitCommand quit = new QuitCommand(); + MenuCommand quit = new MenuCommand(new SaveService(savesDir)); registry.register(quit, "quit"); Game game = new Game(ctx, registry, new CommandParser()); quit.bind(game); From 361fd0939cf536d45c91d985a1396be58f2c0c81 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:25:01 +0200 Subject: [PATCH 17/20] refactor(app): drop dead newSession param, unused import, restore main javadoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `saves` parameter from `newSession(GameIO, SaveService)` → signature is now `newSession(GameIO)`, call site updated accordingly - Remove unused `import thb.jeanluc.adventure.menu.MenuAction` (switch uses unqualified constants; import was dead, compile confirmed clean) - Restore `/** Standard JVM entry point. @param args ignored */` Javadoc on `main` and restore full log message "Fatal error during game startup" Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/java/thb/jeanluc/adventure/App.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index e20b232..8444308 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -26,7 +26,6 @@ import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.menu.MainMenu; -import thb.jeanluc.adventure.menu.MenuAction; import thb.jeanluc.adventure.menu.SettingsMenu; import thb.jeanluc.adventure.save.SaveException; import thb.jeanluc.adventure.save.SaveService; @@ -48,12 +47,13 @@ public final class App { private App() { } + /** Standard JVM entry point. @param args ignored */ public static void main(String[] args) { GameIO io = new ConsoleIO(); try { run(io); } catch (RuntimeException e) { - log.error("Fatal error", e); + log.error("Fatal error during game startup", e); io.write("Fatal error: " + e.getMessage()); System.exit(1); } @@ -70,7 +70,7 @@ public final class App { boolean running = true; while (running) { switch (MainMenu.show(io)) { - case NEW_GAME -> play(io, saves, newSession(io, saves)); + case NEW_GAME -> play(io, saves, newSession(io)); case LOAD -> { SaveSlotInfo slot = MainMenu.pickSlot(io, saves); if (slot != null) { @@ -89,7 +89,7 @@ public final class App { } /** Loads a fresh world and binds it to a newly named session. */ - private static GameSession newSession(GameIO io, SaveService saves) { + private static GameSession newSession(GameIO io) { WorldLoader.LoadResult loaded = new WorldLoader().load(); String name = MainMenu.promptName(io, "Manor"); return new GameSession(loaded.world(), loaded.player(), name); From 98b1561ef266a54c598d51d57e1809e34567b294 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:27:15 +0200 Subject: [PATCH 18/20] feat(io): SwingIO.choose renders the menu as buttons --- .../thb/jeanluc/adventure/io/SwingIO.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index fb4e4be..e40210f 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -10,7 +10,11 @@ import thb.jeanluc.adventure.io.text.StyledText; import javax.swing.AbstractAction; import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; import javax.swing.JComponent; +import javax.swing.JDialog; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JPanel; @@ -342,6 +346,51 @@ public class SwingIO implements GameIO { // The GUI map panel is always visible; nothing extra to do on the 'map' command. } + @Override + public int choose(String title, List options) { + // Same EDT↔worker handoff as readLine: the worker blocks on take(), + // the EDT (button click / window close) offers the chosen index. + LinkedBlockingQueue picked = new LinkedBlockingQueue<>(); + SwingUtilities.invokeLater(() -> { + JDialog dialog = new JDialog(frame, title, true); + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + panel.add(new JLabel(title)); + panel.add(Box.createVerticalStrut(8)); + for (int i = 0; i < options.size(); i++) { + int idx = i; + JButton b = new JButton(options.get(i)); + b.setAlignmentX(0f); + b.addActionListener(e -> { + dialog.dispose(); + picked.offer(idx); + }); + panel.add(b); + panel.add(Box.createVerticalStrut(4)); + } + // Closing the dialog counts as the last (safe/cancel) option. + dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + dialog.addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(java.awt.event.WindowEvent e) { + dialog.dispose(); + picked.offer(options.size() - 1); + } + }); + dialog.setContentPane(panel); + dialog.pack(); + dialog.setLocationRelativeTo(frame); + dialog.setVisible(true); + }); + try { + return picked.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return options.size() - 1; + } + } + private void addList(List spans, List xs, Style st) { for (int i = 0; i < xs.size(); i++) { if (i > 0) { From 88e959adee5312623161cf1875069587f643d5ae Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:30:51 +0200 Subject: [PATCH 19/20] docs: mark main menu + save/load implemented; note minimal settings scope Co-Authored-By: Claude Sonnet 4.6 --- Semesterprojekt/docs/enhancement-ideas.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 883c3b7..8a19c7e 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -144,6 +144,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ### 6. Hauptmenü & Settings +> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb +> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue +> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons). +> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live +> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles +> weiter zurückgestellt (Features existieren noch nicht). + - **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*. - **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl, Zeitstempel) anzeigen und hineinladen. @@ -154,6 +161,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ### 7. Speichern / Laden +> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als +> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/ +> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`, +> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern + +> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü. +> `saves/` ist gitignored. + - Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume, Quest-Fortschritt) – Format z.B. YAML/JSON. - Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü. @@ -175,6 +189,9 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ## Festgelegte Erweiterungs-Entscheidungen +> **Pathfinding** (BFS `go to `, Trie-Autocomplete) bleibt für eine +> spätere Runde im Scope. + | Entscheidung | Wert | |---|---| | Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) | From 182a6911a0f039005747aecbda03e38d36ac5293 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:39:08 +0200 Subject: [PATCH 20/20] fix: EOF ends game loop (ConsoleIO returns null on EOF); settings.json excluded from save slots Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/ConsoleIO.java | 7 ++++--- .../jeanluc/adventure/save/SaveService.java | 13 +++++++++++-- .../adventure/io/ConsoleIORenderTest.java | 19 +++++++++++++++++++ .../adventure/save/SaveServiceTest.java | 17 +++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java index aaf7c26..ec1afc6 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -71,10 +71,11 @@ public class ConsoleIO implements GameIO { out.print("> "); out.flush(); try { - String line = in.readLine(); - return line == null ? "" : line; + // null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts). + return in.readLine(); } catch (IOException e) { - return ""; + // Treat a stream failure as EOF so input ends cleanly rather than spinning. + return null; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java index b5cf690..f812d4f 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -23,6 +23,8 @@ import java.util.stream.Stream; @Slf4j public class SaveService { + private static final String SETTINGS_FILENAME = "settings.json"; + private final Path dir; private final ObjectMapper mapper = new ObjectMapper(); @@ -38,7 +40,11 @@ public class SaveService { /** Slug used for the on-disk filename of a slot name. */ public static String slug(String slotName) { String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_"); - return s.isBlank() ? "save" : s; + if (s.isBlank()) { + return "save"; + } + // Never let a user-named save collide with the settings file. + return s.equals("settings") ? "settings_" : s; } /** Writes the session to {@code .json} atomically. */ @@ -71,7 +77,10 @@ public class SaveService { } List out = new ArrayList<>(); try (Stream files = Files.list(dir)) { - for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) { + for (Path f : files + .filter(p -> p.toString().endsWith(".json")) + .filter(p -> !p.getFileName().toString().equals(SETTINGS_FILENAME)) + .toList()) { try { SaveData d = mapper.readValue(f.toFile(), SaveData.class); String slug = f.getFileName().toString().replaceFirst("\\.json$", ""); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java index 21735b1..19db2e3 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java @@ -50,6 +50,25 @@ class ConsoleIORenderTest { assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off"); } + @Test + void readLine_returnsNullOnEof() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), ps, + ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + assertThat(io.readLine()).isNull(); + } + + @Test + void readLine_returnsEmptyStringForBlankLineThenNullAtEof() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("\n")), ps, + ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + assertThat(io.readLine()).isEqualTo(""); // blank interactive line, re-prompt + assertThat(io.readLine()).isNull(); // stream now exhausted → EOF + } + @Test void asciiGlyphMode_usesPlusDashFrame() { ByteArrayOutputStream sink = new ByteArrayOutputStream(); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java index e321a87..f5aba96 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -55,6 +55,23 @@ class SaveServiceTest { assertThat(new SaveService(dir).list()).isEmpty(); } + @Test + void listExcludesSettingsFile(@TempDir Path dir) throws Exception { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("Real Save"); + svc.save(s); + Files.writeString(dir.resolve("settings.json"), "{\"color\":\"on\"}"); + + List slots = svc.list(); + assertThat(slots).hasSize(1); + assertThat(slots.getFirst().slotName()).isEqualTo("Real Save"); + } + + @Test + void slugForSettingsDoesNotCollideWithSettingsFile() { + assertThat(SaveService.slug("settings")).isNotEqualTo("settings"); + } + @Test void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception { Files.writeString(dir.resolve("bad.json"), "{ not json");