# 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.