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>
2022 lines
69 KiB
Markdown
2022 lines
69 KiB
Markdown
# 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.
|