Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md
Jean-Luc Makiola aa0852cf10 docs: implementation plan for main menu + save/load
13 TDD tasks: GameSession + delegation, restore hooks, GameIO.choose,
SaveData/SaveCodec/SaveService (JSON), Settings + mutable ConsoleIO,
menu package, save/menu commands, quest autosave, App shell loop,
SwingIO buttons, docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:11:42 +02:00

69 KiB
Raw Blame History

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:

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:

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:

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:
                ctx.getSession().incrementTurn();
                if (ctx.getSession().getTurn() % 10 == 0) {
                    ctx.save();
                }
  1. In maybeEnd(), replace EndingEngine.render(e, ctx, turn) with EndingEngine.render(e, ctx, ctx.getSession().getTurn()).
  2. 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
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:

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:

    /** Replaces all flags with the given collection (used by load). */
    public void restore(Collection<String> 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:

    /** Replaces all progress with the given stage map and completed set (load). */
    public void restore(Map<String, Integer> stages, Collection<String> 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():

    /**
     * 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
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:

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:

    /**
     * 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<String> 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
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:

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:

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<String> visitedRoomIds,
        int gold,
        List<String> inventoryItemIds,
        List<String> flags,
        Map<String, Integer> questStages,
        List<String> questCompleted,
        Map<String, List<String>> roomItemIds,
        Map<String, Boolean> switchStates
) {
    /** Current on-disk schema version. */
    public static final int CURRENT_VERSION = 1;
}

src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.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:

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
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:

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<String, Item> items = new LinkedHashMap<>();
        items.put("shovel", shovel);
        items.put("lamp", lamp);
        Map<String, Room> 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:

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<String, List<String>> roomItems = new LinkedHashMap<>();
        for (Room room : w.getRooms().values()) {
            if (!room.getItems().isEmpty()) {
                roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet()));
            }
        }
        Map<String, Boolean> switches = new LinkedHashMap<>();
        for (Item item : w.getItems().values()) {
            if (item instanceof SwitchableItem sw) {
                switches.put(sw.getId(), sw.isOn());
            }
        }
        Map<String, Integer> 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<String, List<String>> 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<String, Boolean> 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
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:

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<SaveSlotInfo> 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:

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 <slug>.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 <slug>.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<SaveSlotInfo> list() {
        if (!Files.isDirectory(dir)) {
            return List.of();
        }
        List<SaveSlotInfo> out = new ArrayList<>();
        try (Stream<Path> 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
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:

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:

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:

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:

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:
    private boolean useColor;
    private GlyphMode glyphs;

(remove final from both). 2. After the 4-arg constructor, add setters:

    /** 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;
    }
  1. 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
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:

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:

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:

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<SaveSlotInfo> slots = saves.list();
        if (slots.isEmpty()) {
            io.write("No saved games.");
            return null;
        }
        List<String> 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:

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
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:

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:

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<String> 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:

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<String> 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
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:

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:

        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
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:

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):

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
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:

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):

    @Override
    public int choose(String title, List<String> options) {
        // Same EDT↔worker handoff as readLine: the worker blocks on take(),
        // the EDT (button click / window close) offers the chosen index.
        LinkedBlockingQueue<Integer> 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
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
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.