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

2022 lines
69 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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:
```java
/** 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()`:
```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<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**
```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<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`:
```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<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`:
```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**
```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<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`:
```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**
```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<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`:
```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<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`:
```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**
```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<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**
```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.