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/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) | diff --git a/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md b/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md new file mode 100644 index 0000000..21317f0 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md @@ -0,0 +1,2021 @@ +# Main Menu + Save/Load Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a main menu above the game loop plus JSON save/load (manual `save`, autosave, quit-to-menu) with a single active slot, and a minimal runtime-togglable settings screen. + +**Architecture:** A shell loop in `App.run(io)` owns the lifecycle (New Game / Load / Settings / Quit) and re-enters after each game ends. The savable state is bundled in a new `GameSession`; `GameContext` delegates to it. Persistence is a JSON `SaveData` *delta* over the freshly YAML-loaded world (`SaveCodec` captures/applies; `SaveService` does disk I/O). The menu uses one new `GameIO.choose(...)` primitive — console gets a numbered-text default, `SwingIO` overrides with buttons. + +**Tech Stack:** Java 25, Maven, Jackson (`jackson-databind` already present → JSON via plain `ObjectMapper`), JUnit 5 + AssertJ + Mockito, Lombok. + +--- + +## File Structure + +**Create:** +- `src/main/java/thb/jeanluc/adventure/game/GameSession.java` — bundles savable state + turn + slot name. +- `src/main/java/thb/jeanluc/adventure/save/SaveData.java` — JSON DTO (record). +- `src/main/java/thb/jeanluc/adventure/save/SaveCodec.java` — pure capture/apply (no disk). +- `src/main/java/thb/jeanluc/adventure/save/SaveService.java` — disk save/list/load (atomic). +- `src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java` — slot metadata record. +- `src/main/java/thb/jeanluc/adventure/save/SaveException.java` — user-friendly failure. +- `src/main/java/thb/jeanluc/adventure/save/Settings.java` — color/glyph prefs record. +- `src/main/java/thb/jeanluc/adventure/save/SettingsStore.java` — load/save settings JSON. +- `src/main/java/thb/jeanluc/adventure/menu/MenuAction.java` — enum. +- `src/main/java/thb/jeanluc/adventure/menu/MainMenu.java` — top menu + slot picker + name prompt. +- `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java` — settings screen. +- `src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java` +- `src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java` — replaces `QuitCommand`. + +**Modify:** +- `io/GameIO.java` — add `choose(...)` default. +- `io/SwingIO.java` — override `choose(...)` with buttons. +- `io/ConsoleIO.java` — make color/glyph mutable + setters. +- `game/GameContext.java` — delegate to `GameSession`; add save callback. +- `game/Game.java` — turn via session; interval autosave. +- `game/GameState.java` — add `restore(...)`. +- `game/QuestEngine.java` — autosave on quest completion. +- `game/QuestLog.java` — add `restore(...)`. +- `model/item/SwitchableItem.java` — add `setState(boolean)`. +- `App.java` — shell loop, registry builder, settings + autosave wiring. +- `.gitignore` — add `saves/`. + +**Delete:** `command/impl/QuitCommand.java` (replaced by `MenuCommand`). + +--- + +## Task 1: GameSession + GameContext delegation + Game turn migration + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/game/GameSession.java` +- Modify: `src/main/java/thb/jeanluc/adventure/game/GameContext.java` +- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java`: +```java +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(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=GameSessionTest` +Expected: FAIL — `GameSession` does not exist / no `getSession()`. + +- [ ] **Step 3: Create `GameSession`** + +`src/main/java/thb/jeanluc/adventure/game/GameSession.java`: +```java +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++; + } +} +``` + +- [ ] **Step 4: Refactor `GameContext` to delegate** + +Replace the whole body of `src/main/java/thb/jeanluc/adventure/game/GameContext.java`: +```java +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.World; + +/** + * 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. + */ +public class GameContext { + + private final GameSession session; + private final GameIO io; + + /** Silent autosave hook, wired by the app; no-op by default (tests). */ + private Runnable saveCallback = () -> { }; + + 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(); + } +} +``` + +- [ ] **Step 5: Migrate `Game` turn to the session + add interval autosave** + +In `src/main/java/thb/jeanluc/adventure/game/Game.java`: +1. Delete the field `private int turn = 0;`. +2. In `run()`, replace `turn++;` with: +```java + ctx.getSession().incrementTurn(); + if (ctx.getSession().getTurn() % 10 == 0) { + ctx.save(); + } +``` +3. In `maybeEnd()`, replace `EndingEngine.render(e, ctx, turn)` with `EndingEngine.render(e, ctx, ctx.getSession().getTurn())`. +4. In `publishHud()`, replace the `turn` argument to `new Hud(...)` with `ctx.getSession().getTurn()`. + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=GameSessionTest,QuestEngineTest` +Expected: PASS (delegation works; existing quest tests still green). + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/GameSession.java \ + src/main/java/thb/jeanluc/adventure/game/GameContext.java \ + src/main/java/thb/jeanluc/adventure/game/Game.java \ + src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java +git commit -m "feat(game): GameSession bundles savable state; turn + autosave hook" +``` + +--- + +## Task 2: Restore hooks — GameState, QuestLog, SwitchableItem + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/game/GameState.java` +- Modify: `src/main/java/thb/jeanluc/adventure/game/QuestLog.java` +- Modify: `src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java`: +```java +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(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=RestoreHooksTest` +Expected: FAIL — `restore` / `setState` not defined. + +- [ ] **Step 3: Add `GameState.restore`** + +In `src/main/java/thb/jeanluc/adventure/game/GameState.java` add the import `java.util.Collection` and the method: +```java + /** Replaces all flags with the given collection (used by load). */ + public void restore(Collection newFlags) { + flags.clear(); + flags.addAll(newFlags); + } +``` + +- [ ] **Step 4: Add `QuestLog.restore`** + +In `src/main/java/thb/jeanluc/adventure/game/QuestLog.java` add imports `java.util.Collection` and the method: +```java + /** 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); + } +``` + +- [ ] **Step 5: Add `SwitchableItem.setState`** + +In `src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java`, after `isOn()`: +```java + /** + * 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; + } +``` + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=RestoreHooksTest` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/GameState.java \ + src/main/java/thb/jeanluc/adventure/game/QuestLog.java \ + src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java \ + src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java +git commit -m "feat: restore hooks for flags, quest progress, switch state" +``` + +--- + +## Task 3: `GameIO.choose` primitive (console default) + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java`: +```java +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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=ChooseDefaultTest` +Expected: FAIL — `choose` not defined. + +- [ ] **Step 3: Add the `choose` default to `GameIO`** + +In `src/main/java/thb/jeanluc/adventure/io/GameIO.java` add `import java.util.List;` and: +```java + /** + * 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() + "."); + } + } +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=ChooseDefaultTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \ + src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java +git commit -m "feat(io): GameIO.choose numbered-menu primitive (console default)" +``` + +--- + +## Task 4: `SaveData` DTO + `SaveSlotInfo` + `SaveException` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveData.java` +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java` +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveException.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java`: +```java +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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveDataTest` +Expected: FAIL — `SaveData` not defined. + +- [ ] **Step 3: Create the records + exception** + +`src/main/java/thb/jeanluc/adventure/save/SaveData.java`: +```java +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; +} +``` + +`src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java`: +```java +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) { +} +``` + +`src/main/java/thb/jeanluc/adventure/save/SaveException.java`: +```java +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(); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=SaveDataTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/SaveData.java \ + src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java \ + src/main/java/thb/jeanluc/adventure/save/SaveException.java \ + src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java +git commit -m "feat(save): SaveData/SaveSlotInfo/SaveException model" +``` + +--- + +## Task 5: `SaveCodec` — capture & apply (pure, no disk) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveCodec.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java`: +```java +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.DOWN, 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"))); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveCodecTest` +Expected: FAIL — `SaveCodec` not defined. + +- [ ] **Step 3: Implement `SaveCodec`** + +`src/main/java/thb/jeanluc/adventure/save/SaveCodec.java`: +```java +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; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=SaveCodecTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/SaveCodec.java \ + src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java +git commit -m "feat(save): SaveCodec capture/apply over a fresh world" +``` + +--- + +## Task 6: `SaveService` — disk save/list/load (atomic) + version check + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveService.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java` + +`SaveService` reloads the world via `WorldLoader` on load. Tests construct it with an explicit save directory (`@TempDir`) and rely on the real classpath world for the round-trip. + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java`: +```java +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")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveServiceTest` +Expected: FAIL — `SaveService` not defined. + +- [ ] **Step 3: Implement `SaveService`** + +`src/main/java/thb/jeanluc/adventure/save/SaveService.java`: +```java +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; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=SaveServiceTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/SaveService.java \ + src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +git commit -m "feat(save): SaveService disk persistence (atomic write, list, load)" +``` + +--- + +## Task 7: `Settings` + `SettingsStore` + mutable `ConsoleIO` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/Settings.java` +- Create: `src/main/java/thb/jeanluc/adventure/save/SettingsStore.java` +- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java` + +- [ ] **Step 1: Write the failing tests** + +`src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java`: +```java +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()); + } +} +``` + +`src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java`: +```java +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 + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn -q test -Dtest=SettingsStoreTest,ConsoleIOSettingsTest` +Expected: FAIL — `Settings`/`SettingsStore`/setters missing. + +- [ ] **Step 3: Create `Settings` + `SettingsStore`** + +`src/main/java/thb/jeanluc/adventure/save/Settings.java`: +```java +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); + } +} +``` + +`src/main/java/thb/jeanluc/adventure/save/SettingsStore.java`: +```java +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); + } + } +} +``` + +- [ ] **Step 4: Make `ConsoleIO` color/glyph mutable** + +In `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java`: +1. Change the fields: +```java + private boolean useColor; + private GlyphMode glyphs; +``` +(remove `final` from both). +2. After the 4-arg constructor, add setters: +```java + /** 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; + } +``` +3. In the 4-arg constructor, replace the `this.useColor = switch (...)` block with `setColorMode(colorMode);` (keeps one resolution path). + +- [ ] **Step 5: Run tests** + +Run: `mvn -q test -Dtest=SettingsStoreTest,ConsoleIOSettingsTest,ConsoleIORenderTest` +Expected: PASS (existing `ConsoleIORenderTest` still green). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/Settings.java \ + src/main/java/thb/jeanluc/adventure/save/SettingsStore.java \ + src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \ + src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java \ + src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java +git commit -m "feat(settings): Settings + SettingsStore; runtime-togglable ConsoleIO" +``` + +--- + +## Task 8: Menu package — `MenuAction`, `MainMenu`, `SettingsMenu` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/menu/MenuAction.java` +- Create: `src/main/java/thb/jeanluc/adventure/menu/MainMenu.java` +- Create: `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java` +- Test: `src/test/java/thb/jeanluc/adventure/menu/MenuTest.java` + +`MainMenu` and `SettingsMenu` are frontend-agnostic — they only call `io.choose`, `io.readLine`, `io.write`. `SettingsMenu` applies a colour/glyph toggle to a `ConsoleIO` (via `instanceof`) and persists through `SettingsStore`; on non-console IO it just persists (GUI renders its own styling). + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/menu/MenuTest.java`: +```java +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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=MenuTest` +Expected: FAIL — menu classes missing. + +- [ ] **Step 3: Create `MenuAction`** + +`src/main/java/thb/jeanluc/adventure/menu/MenuAction.java`: +```java +package thb.jeanluc.adventure.menu; + +/** Top-level main-menu choices, in display order. */ +public enum MenuAction { + NEW_GAME, LOAD, SETTINGS, QUIT +} +``` + +- [ ] **Step 4: Create `MainMenu`** + +`src/main/java/thb/jeanluc/adventure/menu/MainMenu.java`: +```java +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; + } +} +``` + +- [ ] **Step 5: Create `SettingsMenu`** + +`src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java`: +```java +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; + }; + } +} +``` + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=MenuTest` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/menu/ \ + src/test/java/thb/jeanluc/adventure/menu/MenuTest.java +git commit -m "feat(menu): MainMenu, SettingsMenu, MenuAction (frontend-agnostic)" +``` + +--- + +## Task 9: In-game commands — `SaveCommand` and `MenuCommand` (replace `QuitCommand`) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java` +- Create: `src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java` +- Delete: `src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java` +- Test: `src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java`: +```java +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 flag flipped: a subsequent run() returns immediately (no input consumed) + assertThat(game).isNotNull(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveMenuCommandTest` +Expected: FAIL — `SaveCommand`/`MenuCommand` missing. + +- [ ] **Step 3: Create `SaveCommand`** + +`src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java`: +```java +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"; + } +} +``` + +- [ ] **Step 4: Create `MenuCommand`** + +(Keep `QuitCommand.java` in place for now — `App.java` still references it; it is removed in Task 11 when the registry is rewired, so every commit compiles.) + +`src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java`: +```java +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)"; + } +} +``` + +- [ ] **Step 5: Run tests** + +Run: `mvn -q test -Dtest=SaveMenuCommandTest` +Expected: PASS. (`QuitCommand` still exists and `App` still compiles; the registry swap + deletion happen in Task 11.) + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java \ + src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java \ + src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java +git commit -m "feat(command): save + menu commands; remove QuitCommand" +``` + +--- + +## Task 10: Autosave on quest completion + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/game/QuestEngine.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java` + +The interval autosave (every 10 turns) was already added to `Game` in Task 1. Here we trigger the silent `ctx.save()` hook when a quest completes. + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java`: +```java +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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=QuestAutosaveTest` +Expected: FAIL — no save on completion. + +- [ ] **Step 3: Trigger save in `QuestEngine.finish`** + +In `src/main/java/thb/jeanluc/adventure/game/QuestEngine.java`, at the END of the `finish` method (after the `ctx.getIo().print(...)` "Quest complete" line), add: +```java + ctx.save(); +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=QuestAutosaveTest,QuestEngineTest` +Expected: PASS (existing `QuestEngineTest` uses the no-op default callback, still green). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/QuestEngine.java \ + src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java +git commit -m "feat(game): autosave on quest completion" +``` + +--- + +## Task 11: Wire the shell loop in `App` + .gitignore + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/App.java` +- Modify: `.gitignore` + +`AppGui` is unchanged — it calls `App.run(io)` on its worker thread, so the GUI gets the menu automatically (with the console-default `choose` until a Swing override is added in Task 12). + +- [ ] **Step 1: Replace `App.java`** + +Replace `src/main/java/thb/jeanluc/adventure/App.java` with: +```java +package thb.jeanluc.adventure; + +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.DropCommand; +import thb.jeanluc.adventure.command.impl.ExamineCommand; +import thb.jeanluc.adventure.command.impl.GiveCommand; +import thb.jeanluc.adventure.command.impl.GoCommand; +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.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; + +/** + * 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 { + + private App() { + } + + public static void main(String[] args) { + GameIO io = new ConsoleIO(); + try { + run(io); + } catch (RuntimeException e) { + log.error("Fatal error", e); + io.write("Fatal error: " + e.getMessage()); + System.exit(1); + } + } + + /** 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(); + 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"); + registry.register(new LookCommand(), "look", "l"); + registry.register(new InventoryCommand(), "inventory", "inv", "i"); + registry.register(new TakeCommand(), "take", "pick", "get"); + registry.register(new DropCommand(), "drop", "put"); + registry.register(new UseCommand(), "use"); + registry.register(new ReadCommand(), "read"); + registry.register(new ExamineCommand(), "examine", "x", "inspect"); + registry.register(new MapCommand(), "map", "m"); + registry.register(new QuestsCommand(), "quests", "log", "journal"); + registry.register(new TalkCommand(), "talk", "speak"); + registry.register(new GiveCommand(), "give"); + registry.register(new SaveCommand(saves), "save"); + + Game game = new Game(ctx, registry, new CommandParser()); + + 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(); + } +} +``` + +- [ ] **Step 2: Add `saves/` to `.gitignore`** + +Append to `.gitignore` (create if missing): +``` +# Local save games and settings +saves/ +``` + +- [ ] **Step 3: Delete the now-unused `QuitCommand`** + +`App` no longer references it (replaced by `MenuCommand`): +```bash +git rm src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java +``` + +- [ ] **Step 4: Full build + test** + +Run: `mvn -q clean test` +Expected: PASS — all tests green, project compiles (no more `QuitCommand` references). + +- [ ] **Step 5: Manual console smoke test** + +Run: `mvn -q -DskipTests package && printf '1\nSmoke\nlook\nsave\nquit\n4\n' | java -jar target/*.jar` (or run the console main class via your IDE). +Expected: main menu appears → New Game → name prompt → room shown → "Game saved." → "Returning to the main menu." → menu → Quit → "Farewell." A `saves/smoke.json` file exists. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/App.java .gitignore +git rm --cached -r --ignore-unmatch saves 2>/dev/null; true +git commit -m "feat(app): main-menu shell loop wiring; gitignore saves/" +``` + +--- + +## Task 12: `SwingIO.choose` — buttons (GUI) + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` + +GUI-only; verified manually (no unit test, per the project's no-GUI-test convention). Uses the same EDT↔worker blocking handoff as `readLine` (a `LinkedBlockingQueue`). + +- [ ] **Step 1: Override `choose` in `SwingIO`** + +Add these imports to `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`: +```java +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +``` +(`java.util.concurrent.LinkedBlockingQueue` is already imported for `inputs`.) +Add the method (anywhere among the public overrides): +```java + @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; + } + } +``` + +- [ ] **Step 2: Build** + +Run: `mvn -q -DskipTests package` +Expected: compiles. + +- [ ] **Step 3: Manual GUI smoke test** + +Run the Swing entry point (`AppGui`). Expected: a modal dialog with **New Game / Load Game / Settings / Quit** buttons appears; clicking New Game prompts for a name in the text field, then the game starts; `quit` saves and the menu dialog reappears; Settings dialog toggles persist. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/SwingIO.java +git commit -m "feat(io): SwingIO.choose renders the menu as buttons" +``` + +--- + +## Task 13: Docs + backlog update + +**Files:** +- Modify: `docs/enhancement-ideas.md` +- Modify: `docs/implementation-status.md` (if it tracks per-feature status) + +- [ ] **Step 1: Mark #6 (menu/settings) and #7 (save/load) as implemented** + +In `docs/enhancement-ideas.md`, add a `> ✅ umgesetzt (Branch feature/main-menu-save-load)` note under sections **6. Hauptmenü & Settings** and **7. Speichern / Laden**, mirroring the style of the existing ✅ notes (e.g. under #3). Note the minimal-settings scope (colour + glyph only) and that music/typewriter toggles remain deferred. Note pathfinding remains in scope for a later round. + +- [ ] **Step 2: Commit** + +```bash +git add docs/enhancement-ideas.md docs/implementation-status.md +git commit -m "docs: mark main menu + save/load implemented; note minimal settings scope" +``` + +--- + +## Final Verification + +- [ ] Run `mvn -q clean test` — all tests pass (existing 67+ plus the new suites). +- [ ] Console smoke (Task 11 Step 4) behaves as described; `saves/*.json` written. +- [ ] GUI smoke (Task 12 Step 3) shows button menu and round-trips a save/load. +- [ ] Load a saved game and confirm: position, inventory, lit lamp / generator state, set flags, and mid-progress quest all restored. +- [ ] `git status` clean; `saves/` is gitignored and untracked. diff --git a/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md b/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md new file mode 100644 index 0000000..f83b93b --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md @@ -0,0 +1,281 @@ +# Spec: Hauptmenü + Speichern/Laden (+ minimale Settings) + +Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von +Phasen 1–7 (Backlog #6 + #7, Teil von #6-Settings). Baut auf vorhandenem +`GameIO`-Abstraktions-Modell, `GameContext` (World/Player/GameState/QuestLog) und +dem `App.run(io)`-Einstieg auf, der von Konsole und GUI geteilt wird. + +## 1. Kontext & Ziel + +Das Spiel startet bisher direkt in die Spielschleife und endet per `quit` / +Ending / EOF mit Prozess-Ende; es gibt keine Persistenz. Ziel: + +- **Hauptmenü oberhalb der Spielschleife** (Neues Spiel · Laden · Einstellungen · + Beenden), identisch in Konsole und GUI über die geteilte `GameIO`-Abstraktion. +- **Speichern/Laden** des veränderlichen Spielzustands als JSON, als **Delta über + die frisch aus YAML geladene Welt** (Welt-Definition bleibt datengetrieben; der + Spielstand enthält nur den mutierten Zustand). +- **Ein aktiver Slot pro laufendem Spiel** (beim Neues-Spiel benannt). Manuelles + `save`, Autosave und Speichern-beim-Verlassen schreiben alle in diesen Slot. +- **Minimale Settings**: Farb-Modus + Glyphen-Modus (ASCII/Unicode) umschalten, + persistiert. + +Bestätigte Entscheidungen (Brainstorming 2026-06-01): +- Menü-Integration: **eine neue `GameIO`-Primitive `choose(...)`** (Konsole-Default + = nummeriertes Textmenü, `SwingIO` überschreibt mit Buttons). Menü-Logik + einmalig geschrieben, Konsolen-Parität gratis. +- Save-Format: **JSON** (maschinengenerierter Zustand; klar getrennt vom + YAML-Content). Jackson ist bereits Abhängigkeit. +- Slot-Modell: **ein aktiver Slot für alles** (save + autosave + quit-save + überschreiben denselben Slot; Menü-Laden listet alle benannten Spielstände). +- `quit`/`exit`/`menu` in-game = **speichern + zurück ins Hauptmenü**; Prozess-Ende + nur über das Hauptmenü-„Beenden". +- Neues Spiel: **Slot-Name abfragen, mit Default** (Enter akzeptiert Default). +- Autosave: **Events (Quest abgeschlossen, quit-to-menu) + Intervall alle 10 Züge**. +- Settings-Datei: **`saves/settings.json`**. + +## 2. Scope + +**In Scope:** +- Shell-Schleife in `App.run(io)` (Menü-Schicht über dem Game-Loop). +- `menu`-Paket: `MainMenu`, `SettingsMenu`, `MenuAction`. +- `GameIO.choose(String title, List options) → int` + Konsole-Default + (nummeriert, Re-Prompt) + `SwingIO`-Override (Buttons). +- `GameSession` (Bündel des speicherbaren Zustands + Slot-Name + `turn`). +- `SaveData`-DTO (JSON) + `SaveService` (save/list/load, atomar) + `SaveSlotInfo`. +- In-Game-Befehle: `save`, `menu` (Aliase `quit`/`exit`). Kein `load`-Befehl. +- Autosave: Quest-Completion-Hook, quit-to-menu, Intervall alle 10 Züge. +- `Settings` + `SettingsStore` (`saves/settings.json`); `ConsoleIO`-Farb-/Glyphen- + Modus zur Laufzeit umschaltbar machen (Felder non-final + Setter). +- Tests: Round-Trip, Overlay, Failure-Fälle, Menü-Logik, Konsolen-`choose`. + +**Out of Scope:** +- Mehrere Slots pro Spiel / getrennter Autosave-Slot (bewusst „ein aktiver Slot"). +- Settings für Musik/Typewriter (Features existieren noch nicht). +- GUI-Hintergrundbilder/„echte" Menü-Screens (Buttons genügen). +- Pathfinding / `go to ` (eigene spätere Runde, in Scope gehalten). +- Migration alter Save-Versionen (nur Versions-Check + graceful refuse). + +## 3. Architektur: Shell-Schleife über dem Loop + +`App.run(io)` wird zur **Shell-Schleife**, die den Lebenszyklus besitzt. Der +Prozess endet nur über Hauptmenü-„Beenden"; Spielende/`menu` kehren ins Menü +zurück. + +``` +App.run(io): + Settings s = SettingsStore.load(); // → auf io anwenden (Farbe/Glyphen) + loop: + switch (MainMenu.show(io)) { + NEW_GAME -> { GameSession g = newSession(promptName(io)); play(io, g); } + LOAD -> { SaveSlotInfo slot = pickSlot(io); // oder „zurück" + if (slot != null) { + try { play(io, SaveService.load(slot.slug())); } + catch (SaveException e) { io.write(e.getUserMessage()); } + } } + SETTINGS -> SettingsMenu.show(io, s); // toggelt + persistiert + wendet an + QUIT -> return; + } +play(io, session): + GameContext ctx = new GameContext(session); // s.u. + CommandRegistry registry = buildRegistry(...); // inkl. save/menu + Game game = new Game(ctx, registry, parser); + Banner + Welcome + erstes look; + game.run(); // kehrt zurück bei menu/quit/Ending/EOF +``` + +`newSession(name)` lädt die Welt frisch aus YAML (`WorldLoader`) und baut eine +neue `GameSession`. `SaveService.load` lädt die Welt ebenso frisch und legt das +Save-Delta darüber. + +## 4. `GameSession` (game) + +Bündelt den **speicherbaren** Zustand an einem Ort (heute über `GameContext` + +privates `turn` in `Game` verstreut). `turn` wandert hierher, damit es gespeichert +und in der Slot-Metadata gezeigt werden kann. + +```java +public class GameSession { + private final World world; // aus YAML geladen (statisch) + private final Player player; + private final GameState state; + private final QuestLog questLog; + private int turn; + private final String slotName; // gebundener aktiver Slot + // getter; getTurn/setTurn/incrementTurn +} +``` + +`GameContext` bekommt einen Konstruktor `GameContext(GameSession, GameIO)` (oder +hält eine `GameSession`-Referenz statt der vier Einzelfelder). Bestehende +Command-Signaturen (`execute(ctx, args)`) bleiben unverändert; `ctx.getWorld()` +etc. delegieren an die Session. `Game` liest/erhöht `turn` über die Session statt +über ein eigenes Feld. + +## 5. Menü-Schicht (menu) + +```java +public enum MenuAction { NEW_GAME, LOAD, SETTINGS, QUIT } + +public final class MainMenu { // frontend-agnostisch + public static MenuAction show(GameIO io) { + int i = io.choose("Haunted Manor", List.of( + "New Game", "Load Game", "Settings", "Quit")); + return MenuAction.values()[i]; // Reihenfolge = Indizes + } +} +``` + +`SettingsMenu.show(GameIO, Settings)` toggelt Farbe/Glyphen über wiederholte +`choose`-Aufrufe (mit „zurück"-Option), persistiert via `SettingsStore` und wendet +live auf das `io` an. `pickSlot`/`promptName` nutzen `choose` bzw. `readLine`. + +### `GameIO.choose` (io) + +Neue Primitive auf `GameIO`: + +```java +/** Zeigt eine Auswahl; gibt den 0-basierten Index der Wahl zurück. */ +default int choose(String title, List options) { + print(/* Titel + nummerierte Liste 1..n als StyledText */); + while (true) { + String line = readLine(); + Integer n = parseInRange(line, 1, options.size()); + if (n != null) return n - 1; + write("Please enter a number between 1 and " + options.size() + "."); + } +} +``` + +- **Konsole** nutzt den Default (nummeriert + Re-Prompt). **EOF/`null` → letzte + Option** (Konvention: Menüs ordnen die sichere/abbrechende Wahl — „Quit" bzw. + „zurück" — stets als letzte Option, sodass EOF nie in einen Zustand führt). +- **`SwingIO` überschreibt** `choose`: rendert Buttons (ein Button je Option), + blockiert den Worker-Thread bis zum Klick — dasselbe Blocking-Handoff-Muster wie + das vorhandene `readLine` (EDT ↔ Worker). Kein Re-Prompt nötig (Buttons sind + immer gültig). + +## 6. In-Game-Befehle (command/impl) + +- **`SaveCommand`** (`save`): `SaveService.save(session)` → „Game saved." / bei + Fehler graceful Meldung, Loop läuft weiter. +- **`MenuCommand`** (`menu`, Aliase `quit`, `exit`): `SaveService.save(session)` + dann `game.stop()` → zurück ins Hauptmenü. Ersetzt den bisherigen `QuitCommand` + (Bindung an `Game` wie gehabt via `bind(game)`). +- **Kein `load`-Befehl** — Laden ausschließlich über das Hauptmenü. + +## 7. Autosave + +Ein aktiver Slot; alle Autosaves überschreiben ihn. Trigger: +- **Quest abgeschlossen** — Hook in `QuestEngine` (nach `questLog.complete(id)`), + ruft eine `Autosaver`/`SaveService.save(session)`-Callback auf. Der Engine darf + dafür Zugriff auf einen Save-Callback bekommen (z.B. via `GameContext`), um die + Schichtung (game → loader) nicht zu verletzen. +- **quit-to-menu** — `MenuCommand` speichert ohnehin. +- **Intervall** — in `Game.run()` nach jedem erfolgreichen Befehl: wenn + `turn % 10 == 0`, `SaveService.save(session)`. + +Autosave-Fehler werden geloggt und schlucken (kein Loop-Abbruch); optional eine +dezente Meldung. + +## 8. Save-Datenmodell: `SaveData` (loader/save) + +JSON-DTO, reines Delta über die YAML-Welt: + +```java +public record SaveData( + int schemaVersion, // aktuell 1; Mismatch → refuse + String worldTitle, // Sanity-Check gegen geladene Welt + String slotName, + long savedAtEpochMillis, // System.currentTimeMillis() + int turn, + String currentRoomId, + List visitedRoomIds, // geordnet + int gold, + List inventoryItemIds, // geordnet + List flags, // gesetzte World-Flags + Map questStages, // aktive Quest → Stage-Index + List questCompleted, + Map> roomItemIds, // roomId → Item-Ids im Raum + Map switchStates // SwitchableItem-Id → on/off +) {} +``` + +`inventoryItemIds` + `roomItemIds` erfassen die Item-Platzierung vollständig; +Items in keinem von beiden gelten als verbraucht/entfernt. Switch-Zustände decken +Lampe/Generator/Tür ab. + +## 9. Persistenz: `SaveService` (loader/save) + +- Verzeichnis `saves/` (relativ zum Arbeitsverzeichnis, on-demand angelegt, in + `.gitignore`). Ein Slot = `saves/.json`, `slug` = sanitisierter Name + (`[a-z0-9_-]`, Rest → `_`). +- **`save(GameSession)`**: `SaveData` aus Session bauen → Jackson → **atomar** + schreiben (Temp-Datei + `Files.move` ATOMIC_MOVE/REPLACE_EXISTING). +- **`list() → List`**: `saves/*.json` scannen, Header-Felder lesen + (`slotName`, `currentRoomId`, `turn`, `savedAtEpochMillis`) → für die + Laden-Liste. Beschädigte Dateien überspringen (geloggt). +- **`load(slug) → GameSession`**: `SaveData` lesen → Welt frisch via `WorldLoader` + → `SaveApplier` legt das Delta darüber → `GameSession`. +- **`SaveApplier`**: löst Ids gegen die frische Welt auf (Räume, Items). Setzt + Player-Raum/Gold/Inventar/Visited, World-Flags, QuestLog (Stages + completed), + verteilt Items in Räume/Inventar, setzt Switch-Zustände, `turn`. + +```java +public record SaveSlotInfo(String slug, String slotName, String roomId, + int turn, long savedAtEpochMillis) {} +``` + +## 10. Minimale Settings (game/io) + +```java +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + public static Settings defaults() { return new Settings(ColorMode.AUTO, GlyphMode.UNICODE); } +} +``` + +- `SettingsStore.load()/save(Settings)` ↔ `saves/settings.json` (Jackson). Fehlend/ + beschädigt → `Settings.defaults()`. +- **`ConsoleIO`**: `useColor`/`glyphs` von `final` auf veränderlich umstellen + + `setColorMode`/`setGlyphMode`, damit ein Settings-Toggle **live** wirkt (heute + konstruktor-fix). `App.main` lädt Settings vor dem Menü und konstruiert/füttert + `ConsoleIO` damit. +- `SwingIO`: Glyphen/Farbe für die GUI ggf. No-Op bzw. minimal (GUI rendert eigene + Stile); Settings-Wirkung primär Konsole. (Bestätigen im Plan.) + +## 11. Fehlerbehandlung + +- **`SaveException`** (eigene, mit `getUserMessage()`): beschädigtes/unlesbares + JSON, Schreibrechte fehlen, Schema-Mismatch, unbekannte Ids → gefangen, geloggt, + benutzerfreundliche Meldung; **nie** Absturz. Load-Fehler → zurück ins Menü. +- Konsolen-`choose`: ungültige Eingabe → Re-Prompt; `null`/EOF → sichere Vorgabe. +- Leere Slot-Liste beim Laden → „No saved games." → zurück ins Menü. +- GUI-Buttons können keine ungültige Wahl liefern. + +## 12. Tests + +- **`SaveServiceTest`** (Temp-Dir): Session bauen → `save` → `load` → volle + Zustandsgleichheit (Raum, Inventar, Item-Platzierung, Flags, Quest-Stages + + completed, Switch-Zustände, Gold, `turn`). +- **`SaveDataTest`**: Jackson (De-)Serialisierung stabil; Overlay auf frische Welt + (verschobene Items, Quest mitten in Stage). +- **Failure**: beschädigtes JSON, unbekannte Raum-Id, Schema-Mismatch → `SaveException` + mit sinnvoller Meldung; `list()` überspringt kaputte Dateien. +- **`MainMenu`/`SettingsMenu`**: Fake-`GameIO` mit skriptbaren `choose`-Ergebnissen; + prüft Aktions-Mapping + Settings-Persistenz. +- **Konsolen-`choose`-Default**: nummeriertes Rendering + Bad-Input-Re-Prompt über + gemocktes `readLine`. +- **Konvention**: `SwingIO.choose`/GUI von Unit-Tests ausgenommen (manuelle + Verifikation), wie etabliert keine YAML-/GUI-abhängigen Domain-Tests. + +## 13. Architektur-Werte / Risiken + +- **Geteilter Loop bleibt zentral**: nur *eine* neue IO-Primitive; Menü-*Logik* ist + frontend-agnostisch, nur das Rendering divergiert (Default vs. Override). +- **Datengetrieben**: Welt weiter aus YAML; Save ist reines Delta — Content-Updates + brechen alte Saves höchstens bei entfernten Ids (→ graceful refuse). +- **Schichtung**: `SaveService` lebt unter `loader` (kennt `WorldLoader`); der + Quest-Autosave-Hook braucht einen Save-Callback im `GameContext`, um `game` nicht + direkt von `loader` abhängig zu machen — im Plan sauber verdrahten. +- **GUI-Threading**: `SwingIO.choose` muss dasselbe EDT↔Worker-Blocking-Muster wie + `readLine` nutzen; sonst Deadlock-Gefahr. diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index c609650..8444308 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -11,23 +11,35 @@ 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.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,11 +47,7 @@ public final class App { private App() { } - /** - * Standard JVM entry point. - * - * @param args ignored - */ + /** Standard JVM entry point. @param args ignored */ public static void main(String[] args) { GameIO io = new ConsoleIO(); try { @@ -51,15 +59,52 @@ public final class App { } } - /** - * 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)); + 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) { 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/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/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/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/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/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/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/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/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java index 8be6545..ec1afc6 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,22 +49,33 @@ 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("> "); 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/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/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) { 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/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/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..0f27340 --- /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.getVisitedRoomIds().clear(); + p.getVisitedRoomIds().addAll(data.visitedRoomIds()); + p.setCurrentRoom(requireRoom(w, data.currentRoomId())); + 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 new file mode 100644 index 0000000..75fa852 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java @@ -0,0 +1,44 @@ +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; + + /** + * 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/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/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java new file mode 100644 index 0000000..f812d4f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -0,0 +1,118 @@ +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 static final String SETTINGS_FILENAME = "settings.json"; + + 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_-]+", "_"); + 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. */ + 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) { + 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); + } + } + + /** 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")) + .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$", ""); + 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/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/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/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 + } +} 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..d493cab --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java @@ -0,0 +1,54 @@ +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(); + assertThat(ctx.getWorld()).isSameAs(w); + assertThat(ctx.getPlayer().getCurrentRoom()).isSameAs(k); + } +} 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); 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); + } +} 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(); + } +} 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); + } +} 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/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/menu/MenuTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java new file mode 100644 index 0000000..3088e3b --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java @@ -0,0 +1,74 @@ +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; + +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"); + } + + 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")); + // 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); + } +} 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..569589e --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java @@ -0,0 +1,107 @@ +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)); + // Post-condition: applying a save with null/empty roomItemIds clears all room item tables. + assertThat(dst.getWorld().getRooms().get("kitchen").findItem("lamp")).isEmpty(); + } +} 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); + } +} 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..f5aba96 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -0,0 +1,93 @@ +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().slug()).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 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"); + 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")); + } +} 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..9cca9fd --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -0,0 +1,37 @@ +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.io.IOException; +import java.nio.file.Files; +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()); + } + + @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()); + } +}