From 2f96b7896b32bef3e1c35049a6e84113018670c3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:00:32 +0200 Subject: [PATCH 01/22] docs: spec for main menu + save/load (+ minimal settings) Brainstormed design for backlog #6/#7: shell loop above the game loop, GameIO.choose() primitive (console default + Swing buttons), GameSession bundling savable state, JSON SaveData delta over the YAML world, single active slot, autosave (quest-complete + quit + every 10 turns), and minimal runtime-togglable settings (color/glyph mode). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-01-main-menu-save-load-design.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md diff --git a/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md b/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md new file mode 100644 index 0000000..f83b93b --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md @@ -0,0 +1,281 @@ +# Spec: Hauptmenü + Speichern/Laden (+ minimale Settings) + +Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von +Phasen 1–7 (Backlog #6 + #7, Teil von #6-Settings). Baut auf vorhandenem +`GameIO`-Abstraktions-Modell, `GameContext` (World/Player/GameState/QuestLog) und +dem `App.run(io)`-Einstieg auf, der von Konsole und GUI geteilt wird. + +## 1. Kontext & Ziel + +Das Spiel startet bisher direkt in die Spielschleife und endet per `quit` / +Ending / EOF mit Prozess-Ende; es gibt keine Persistenz. Ziel: + +- **Hauptmenü oberhalb der Spielschleife** (Neues Spiel · Laden · Einstellungen · + Beenden), identisch in Konsole und GUI über die geteilte `GameIO`-Abstraktion. +- **Speichern/Laden** des veränderlichen Spielzustands als JSON, als **Delta über + die frisch aus YAML geladene Welt** (Welt-Definition bleibt datengetrieben; der + Spielstand enthält nur den mutierten Zustand). +- **Ein aktiver Slot pro laufendem Spiel** (beim Neues-Spiel benannt). Manuelles + `save`, Autosave und Speichern-beim-Verlassen schreiben alle in diesen Slot. +- **Minimale Settings**: Farb-Modus + Glyphen-Modus (ASCII/Unicode) umschalten, + persistiert. + +Bestätigte Entscheidungen (Brainstorming 2026-06-01): +- Menü-Integration: **eine neue `GameIO`-Primitive `choose(...)`** (Konsole-Default + = nummeriertes Textmenü, `SwingIO` überschreibt mit Buttons). Menü-Logik + einmalig geschrieben, Konsolen-Parität gratis. +- Save-Format: **JSON** (maschinengenerierter Zustand; klar getrennt vom + YAML-Content). Jackson ist bereits Abhängigkeit. +- Slot-Modell: **ein aktiver Slot für alles** (save + autosave + quit-save + überschreiben denselben Slot; Menü-Laden listet alle benannten Spielstände). +- `quit`/`exit`/`menu` in-game = **speichern + zurück ins Hauptmenü**; Prozess-Ende + nur über das Hauptmenü-„Beenden". +- Neues Spiel: **Slot-Name abfragen, mit Default** (Enter akzeptiert Default). +- Autosave: **Events (Quest abgeschlossen, quit-to-menu) + Intervall alle 10 Züge**. +- Settings-Datei: **`saves/settings.json`**. + +## 2. Scope + +**In Scope:** +- Shell-Schleife in `App.run(io)` (Menü-Schicht über dem Game-Loop). +- `menu`-Paket: `MainMenu`, `SettingsMenu`, `MenuAction`. +- `GameIO.choose(String title, List options) → int` + Konsole-Default + (nummeriert, Re-Prompt) + `SwingIO`-Override (Buttons). +- `GameSession` (Bündel des speicherbaren Zustands + Slot-Name + `turn`). +- `SaveData`-DTO (JSON) + `SaveService` (save/list/load, atomar) + `SaveSlotInfo`. +- In-Game-Befehle: `save`, `menu` (Aliase `quit`/`exit`). Kein `load`-Befehl. +- Autosave: Quest-Completion-Hook, quit-to-menu, Intervall alle 10 Züge. +- `Settings` + `SettingsStore` (`saves/settings.json`); `ConsoleIO`-Farb-/Glyphen- + Modus zur Laufzeit umschaltbar machen (Felder non-final + Setter). +- Tests: Round-Trip, Overlay, Failure-Fälle, Menü-Logik, Konsolen-`choose`. + +**Out of Scope:** +- Mehrere Slots pro Spiel / getrennter Autosave-Slot (bewusst „ein aktiver Slot"). +- Settings für Musik/Typewriter (Features existieren noch nicht). +- GUI-Hintergrundbilder/„echte" Menü-Screens (Buttons genügen). +- Pathfinding / `go to ` (eigene spätere Runde, in Scope gehalten). +- Migration alter Save-Versionen (nur Versions-Check + graceful refuse). + +## 3. Architektur: Shell-Schleife über dem Loop + +`App.run(io)` wird zur **Shell-Schleife**, die den Lebenszyklus besitzt. Der +Prozess endet nur über Hauptmenü-„Beenden"; Spielende/`menu` kehren ins Menü +zurück. + +``` +App.run(io): + Settings s = SettingsStore.load(); // → auf io anwenden (Farbe/Glyphen) + loop: + switch (MainMenu.show(io)) { + NEW_GAME -> { GameSession g = newSession(promptName(io)); play(io, g); } + LOAD -> { SaveSlotInfo slot = pickSlot(io); // oder „zurück" + if (slot != null) { + try { play(io, SaveService.load(slot.slug())); } + catch (SaveException e) { io.write(e.getUserMessage()); } + } } + SETTINGS -> SettingsMenu.show(io, s); // toggelt + persistiert + wendet an + QUIT -> return; + } +play(io, session): + GameContext ctx = new GameContext(session); // s.u. + CommandRegistry registry = buildRegistry(...); // inkl. save/menu + Game game = new Game(ctx, registry, parser); + Banner + Welcome + erstes look; + game.run(); // kehrt zurück bei menu/quit/Ending/EOF +``` + +`newSession(name)` lädt die Welt frisch aus YAML (`WorldLoader`) und baut eine +neue `GameSession`. `SaveService.load` lädt die Welt ebenso frisch und legt das +Save-Delta darüber. + +## 4. `GameSession` (game) + +Bündelt den **speicherbaren** Zustand an einem Ort (heute über `GameContext` + +privates `turn` in `Game` verstreut). `turn` wandert hierher, damit es gespeichert +und in der Slot-Metadata gezeigt werden kann. + +```java +public class GameSession { + private final World world; // aus YAML geladen (statisch) + private final Player player; + private final GameState state; + private final QuestLog questLog; + private int turn; + private final String slotName; // gebundener aktiver Slot + // getter; getTurn/setTurn/incrementTurn +} +``` + +`GameContext` bekommt einen Konstruktor `GameContext(GameSession, GameIO)` (oder +hält eine `GameSession`-Referenz statt der vier Einzelfelder). Bestehende +Command-Signaturen (`execute(ctx, args)`) bleiben unverändert; `ctx.getWorld()` +etc. delegieren an die Session. `Game` liest/erhöht `turn` über die Session statt +über ein eigenes Feld. + +## 5. Menü-Schicht (menu) + +```java +public enum MenuAction { NEW_GAME, LOAD, SETTINGS, QUIT } + +public final class MainMenu { // frontend-agnostisch + public static MenuAction show(GameIO io) { + int i = io.choose("Haunted Manor", List.of( + "New Game", "Load Game", "Settings", "Quit")); + return MenuAction.values()[i]; // Reihenfolge = Indizes + } +} +``` + +`SettingsMenu.show(GameIO, Settings)` toggelt Farbe/Glyphen über wiederholte +`choose`-Aufrufe (mit „zurück"-Option), persistiert via `SettingsStore` und wendet +live auf das `io` an. `pickSlot`/`promptName` nutzen `choose` bzw. `readLine`. + +### `GameIO.choose` (io) + +Neue Primitive auf `GameIO`: + +```java +/** Zeigt eine Auswahl; gibt den 0-basierten Index der Wahl zurück. */ +default int choose(String title, List options) { + print(/* Titel + nummerierte Liste 1..n als StyledText */); + while (true) { + String line = readLine(); + Integer n = parseInRange(line, 1, options.size()); + if (n != null) return n - 1; + write("Please enter a number between 1 and " + options.size() + "."); + } +} +``` + +- **Konsole** nutzt den Default (nummeriert + Re-Prompt). **EOF/`null` → letzte + Option** (Konvention: Menüs ordnen die sichere/abbrechende Wahl — „Quit" bzw. + „zurück" — stets als letzte Option, sodass EOF nie in einen Zustand führt). +- **`SwingIO` überschreibt** `choose`: rendert Buttons (ein Button je Option), + blockiert den Worker-Thread bis zum Klick — dasselbe Blocking-Handoff-Muster wie + das vorhandene `readLine` (EDT ↔ Worker). Kein Re-Prompt nötig (Buttons sind + immer gültig). + +## 6. In-Game-Befehle (command/impl) + +- **`SaveCommand`** (`save`): `SaveService.save(session)` → „Game saved." / bei + Fehler graceful Meldung, Loop läuft weiter. +- **`MenuCommand`** (`menu`, Aliase `quit`, `exit`): `SaveService.save(session)` + dann `game.stop()` → zurück ins Hauptmenü. Ersetzt den bisherigen `QuitCommand` + (Bindung an `Game` wie gehabt via `bind(game)`). +- **Kein `load`-Befehl** — Laden ausschließlich über das Hauptmenü. + +## 7. Autosave + +Ein aktiver Slot; alle Autosaves überschreiben ihn. Trigger: +- **Quest abgeschlossen** — Hook in `QuestEngine` (nach `questLog.complete(id)`), + ruft eine `Autosaver`/`SaveService.save(session)`-Callback auf. Der Engine darf + dafür Zugriff auf einen Save-Callback bekommen (z.B. via `GameContext`), um die + Schichtung (game → loader) nicht zu verletzen. +- **quit-to-menu** — `MenuCommand` speichert ohnehin. +- **Intervall** — in `Game.run()` nach jedem erfolgreichen Befehl: wenn + `turn % 10 == 0`, `SaveService.save(session)`. + +Autosave-Fehler werden geloggt und schlucken (kein Loop-Abbruch); optional eine +dezente Meldung. + +## 8. Save-Datenmodell: `SaveData` (loader/save) + +JSON-DTO, reines Delta über die YAML-Welt: + +```java +public record SaveData( + int schemaVersion, // aktuell 1; Mismatch → refuse + String worldTitle, // Sanity-Check gegen geladene Welt + String slotName, + long savedAtEpochMillis, // System.currentTimeMillis() + int turn, + String currentRoomId, + List visitedRoomIds, // geordnet + int gold, + List inventoryItemIds, // geordnet + List flags, // gesetzte World-Flags + Map questStages, // aktive Quest → Stage-Index + List questCompleted, + Map> roomItemIds, // roomId → Item-Ids im Raum + Map switchStates // SwitchableItem-Id → on/off +) {} +``` + +`inventoryItemIds` + `roomItemIds` erfassen die Item-Platzierung vollständig; +Items in keinem von beiden gelten als verbraucht/entfernt. Switch-Zustände decken +Lampe/Generator/Tür ab. + +## 9. Persistenz: `SaveService` (loader/save) + +- Verzeichnis `saves/` (relativ zum Arbeitsverzeichnis, on-demand angelegt, in + `.gitignore`). Ein Slot = `saves/.json`, `slug` = sanitisierter Name + (`[a-z0-9_-]`, Rest → `_`). +- **`save(GameSession)`**: `SaveData` aus Session bauen → Jackson → **atomar** + schreiben (Temp-Datei + `Files.move` ATOMIC_MOVE/REPLACE_EXISTING). +- **`list() → List`**: `saves/*.json` scannen, Header-Felder lesen + (`slotName`, `currentRoomId`, `turn`, `savedAtEpochMillis`) → für die + Laden-Liste. Beschädigte Dateien überspringen (geloggt). +- **`load(slug) → GameSession`**: `SaveData` lesen → Welt frisch via `WorldLoader` + → `SaveApplier` legt das Delta darüber → `GameSession`. +- **`SaveApplier`**: löst Ids gegen die frische Welt auf (Räume, Items). Setzt + Player-Raum/Gold/Inventar/Visited, World-Flags, QuestLog (Stages + completed), + verteilt Items in Räume/Inventar, setzt Switch-Zustände, `turn`. + +```java +public record SaveSlotInfo(String slug, String slotName, String roomId, + int turn, long savedAtEpochMillis) {} +``` + +## 10. Minimale Settings (game/io) + +```java +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + public static Settings defaults() { return new Settings(ColorMode.AUTO, GlyphMode.UNICODE); } +} +``` + +- `SettingsStore.load()/save(Settings)` ↔ `saves/settings.json` (Jackson). Fehlend/ + beschädigt → `Settings.defaults()`. +- **`ConsoleIO`**: `useColor`/`glyphs` von `final` auf veränderlich umstellen + + `setColorMode`/`setGlyphMode`, damit ein Settings-Toggle **live** wirkt (heute + konstruktor-fix). `App.main` lädt Settings vor dem Menü und konstruiert/füttert + `ConsoleIO` damit. +- `SwingIO`: Glyphen/Farbe für die GUI ggf. No-Op bzw. minimal (GUI rendert eigene + Stile); Settings-Wirkung primär Konsole. (Bestätigen im Plan.) + +## 11. Fehlerbehandlung + +- **`SaveException`** (eigene, mit `getUserMessage()`): beschädigtes/unlesbares + JSON, Schreibrechte fehlen, Schema-Mismatch, unbekannte Ids → gefangen, geloggt, + benutzerfreundliche Meldung; **nie** Absturz. Load-Fehler → zurück ins Menü. +- Konsolen-`choose`: ungültige Eingabe → Re-Prompt; `null`/EOF → sichere Vorgabe. +- Leere Slot-Liste beim Laden → „No saved games." → zurück ins Menü. +- GUI-Buttons können keine ungültige Wahl liefern. + +## 12. Tests + +- **`SaveServiceTest`** (Temp-Dir): Session bauen → `save` → `load` → volle + Zustandsgleichheit (Raum, Inventar, Item-Platzierung, Flags, Quest-Stages + + completed, Switch-Zustände, Gold, `turn`). +- **`SaveDataTest`**: Jackson (De-)Serialisierung stabil; Overlay auf frische Welt + (verschobene Items, Quest mitten in Stage). +- **Failure**: beschädigtes JSON, unbekannte Raum-Id, Schema-Mismatch → `SaveException` + mit sinnvoller Meldung; `list()` überspringt kaputte Dateien. +- **`MainMenu`/`SettingsMenu`**: Fake-`GameIO` mit skriptbaren `choose`-Ergebnissen; + prüft Aktions-Mapping + Settings-Persistenz. +- **Konsolen-`choose`-Default**: nummeriertes Rendering + Bad-Input-Re-Prompt über + gemocktes `readLine`. +- **Konvention**: `SwingIO.choose`/GUI von Unit-Tests ausgenommen (manuelle + Verifikation), wie etabliert keine YAML-/GUI-abhängigen Domain-Tests. + +## 13. Architektur-Werte / Risiken + +- **Geteilter Loop bleibt zentral**: nur *eine* neue IO-Primitive; Menü-*Logik* ist + frontend-agnostisch, nur das Rendering divergiert (Default vs. Override). +- **Datengetrieben**: Welt weiter aus YAML; Save ist reines Delta — Content-Updates + brechen alte Saves höchstens bei entfernten Ids (→ graceful refuse). +- **Schichtung**: `SaveService` lebt unter `loader` (kennt `WorldLoader`); der + Quest-Autosave-Hook braucht einen Save-Callback im `GameContext`, um `game` nicht + direkt von `loader` abhängig zu machen — im Plan sauber verdrahten. +- **GUI-Threading**: `SwingIO.choose` muss dasselbe EDT↔Worker-Blocking-Muster wie + `readLine` nutzen; sonst Deadlock-Gefahr. From aa0852cf106d50053cba27eeb88cadc8a1632c82 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:11:42 +0200 Subject: [PATCH 02/22] 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) --- .../plans/2026-06-01-main-menu-save-load.md | 2021 +++++++++++++++++ 1 file changed, 2021 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md diff --git a/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md b/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md new file mode 100644 index 0000000..21317f0 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-06-01-main-menu-save-load.md @@ -0,0 +1,2021 @@ +# Main Menu + Save/Load Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a main menu above the game loop plus JSON save/load (manual `save`, autosave, quit-to-menu) with a single active slot, and a minimal runtime-togglable settings screen. + +**Architecture:** A shell loop in `App.run(io)` owns the lifecycle (New Game / Load / Settings / Quit) and re-enters after each game ends. The savable state is bundled in a new `GameSession`; `GameContext` delegates to it. Persistence is a JSON `SaveData` *delta* over the freshly YAML-loaded world (`SaveCodec` captures/applies; `SaveService` does disk I/O). The menu uses one new `GameIO.choose(...)` primitive — console gets a numbered-text default, `SwingIO` overrides with buttons. + +**Tech Stack:** Java 25, Maven, Jackson (`jackson-databind` already present → JSON via plain `ObjectMapper`), JUnit 5 + AssertJ + Mockito, Lombok. + +--- + +## File Structure + +**Create:** +- `src/main/java/thb/jeanluc/adventure/game/GameSession.java` — bundles savable state + turn + slot name. +- `src/main/java/thb/jeanluc/adventure/save/SaveData.java` — JSON DTO (record). +- `src/main/java/thb/jeanluc/adventure/save/SaveCodec.java` — pure capture/apply (no disk). +- `src/main/java/thb/jeanluc/adventure/save/SaveService.java` — disk save/list/load (atomic). +- `src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java` — slot metadata record. +- `src/main/java/thb/jeanluc/adventure/save/SaveException.java` — user-friendly failure. +- `src/main/java/thb/jeanluc/adventure/save/Settings.java` — color/glyph prefs record. +- `src/main/java/thb/jeanluc/adventure/save/SettingsStore.java` — load/save settings JSON. +- `src/main/java/thb/jeanluc/adventure/menu/MenuAction.java` — enum. +- `src/main/java/thb/jeanluc/adventure/menu/MainMenu.java` — top menu + slot picker + name prompt. +- `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java` — settings screen. +- `src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java` +- `src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java` — replaces `QuitCommand`. + +**Modify:** +- `io/GameIO.java` — add `choose(...)` default. +- `io/SwingIO.java` — override `choose(...)` with buttons. +- `io/ConsoleIO.java` — make color/glyph mutable + setters. +- `game/GameContext.java` — delegate to `GameSession`; add save callback. +- `game/Game.java` — turn via session; interval autosave. +- `game/GameState.java` — add `restore(...)`. +- `game/QuestEngine.java` — autosave on quest completion. +- `game/QuestLog.java` — add `restore(...)`. +- `model/item/SwitchableItem.java` — add `setState(boolean)`. +- `App.java` — shell loop, registry builder, settings + autosave wiring. +- `.gitignore` — add `saves/`. + +**Delete:** `command/impl/QuitCommand.java` (replaced by `MenuCommand`). + +--- + +## Task 1: GameSession + GameContext delegation + Game turn migration + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/game/GameSession.java` +- Modify: `src/main/java/thb/jeanluc/adventure/game/GameContext.java` +- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameSessionTest { + + private GameSession session() { + Room k = new Room("k", "Kitchen", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w"); + return new GameSession(w, new Player(k, 0), "slot-a"); + } + + @Test + void exposesStateAndIncrementsTurn() { + GameSession s = session(); + assertThat(s.getSlotName()).isEqualTo("slot-a"); + assertThat(s.getTurn()).isZero(); + s.incrementTurn(); + s.incrementTurn(); + assertThat(s.getTurn()).isEqualTo(2); + s.setTurn(10); + assertThat(s.getTurn()).isEqualTo(10); + } + + @Test + void contextDelegatesToSession() { + GameSession s = session(); + GameContext ctx = new GameContext(s, new TestIO()); + assertThat(ctx.getWorld()).isSameAs(s.getWorld()); + assertThat(ctx.getPlayer()).isSameAs(s.getPlayer()); + assertThat(ctx.getState()).isSameAs(s.getState()); + assertThat(ctx.getQuestLog()).isSameAs(s.getQuestLog()); + assertThat(ctx.getSession()).isSameAs(s); + } + + @Test + void legacyConstructorBuildsFreshSession() { + Room k = new Room("k", "Kitchen", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w"); + GameContext ctx = new GameContext(w, new Player(k, 0), new TestIO()); + assertThat(ctx.getSession()).isNotNull(); + assertThat(ctx.getSession().getTurn()).isZero(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=GameSessionTest` +Expected: FAIL — `GameSession` does not exist / no `getSession()`. + +- [ ] **Step 3: Create `GameSession`** + +`src/main/java/thb/jeanluc/adventure/game/GameSession.java`: +```java +package thb.jeanluc.adventure.game; + +import lombok.Getter; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.World; + +/** + * Bundle of the mutable, savable game state for one playthrough: the + * loaded {@link World}, the {@link Player}, world {@link GameState} flags, + * {@link QuestLog} progress, the turn counter, and the bound save-slot name. + * Save/load read and write exactly this object. + */ +@Getter +public class GameSession { + + private final World world; + private final Player player; + private final GameState state = new GameState(); + private final QuestLog questLog = new QuestLog(); + private final String slotName; + private int turn; + + public GameSession(World world, Player player, String slotName) { + this.world = world; + this.player = player; + this.slotName = slotName; + } + + public void setTurn(int turn) { + this.turn = turn; + } + + public void incrementTurn() { + this.turn++; + } +} +``` + +- [ ] **Step 4: Refactor `GameContext` to delegate** + +Replace the whole body of `src/main/java/thb/jeanluc/adventure/game/GameContext.java`: +```java +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.World; + +/** + * Bundle of everything a command needs: the active {@link GameSession} + * (world, player, flags, quests, turn) and the IO channel. Getters delegate + * to the session so existing command code keeps working unchanged. + */ +public class GameContext { + + private final GameSession session; + private final GameIO io; + + /** Silent autosave hook, wired by the app; no-op by default (tests). */ + private Runnable saveCallback = () -> { }; + + public GameContext(GameSession session, GameIO io) { + this.session = session; + this.io = io; + } + + /** Convenience for tests/legacy callers: wraps a fresh single-use session. */ + public GameContext(World world, Player player, GameIO io) { + this(new GameSession(world, player, "session"), io); + } + + public GameSession getSession() { + return session; + } + + public World getWorld() { + return session.getWorld(); + } + + public Player getPlayer() { + return session.getPlayer(); + } + + public GameState getState() { + return session.getState(); + } + + public QuestLog getQuestLog() { + return session.getQuestLog(); + } + + public GameIO getIo() { + return io; + } + + /** Sets the silent autosave hook (called by interval/quest autosave). */ + public void setSaveCallback(Runnable saveCallback) { + this.saveCallback = saveCallback; + } + + /** Runs the silent autosave hook. */ + public void save() { + saveCallback.run(); + } +} +``` + +- [ ] **Step 5: Migrate `Game` turn to the session + add interval autosave** + +In `src/main/java/thb/jeanluc/adventure/game/Game.java`: +1. Delete the field `private int turn = 0;`. +2. In `run()`, replace `turn++;` with: +```java + ctx.getSession().incrementTurn(); + if (ctx.getSession().getTurn() % 10 == 0) { + ctx.save(); + } +``` +3. In `maybeEnd()`, replace `EndingEngine.render(e, ctx, turn)` with `EndingEngine.render(e, ctx, ctx.getSession().getTurn())`. +4. In `publishHud()`, replace the `turn` argument to `new Hud(...)` with `ctx.getSession().getTurn()`. + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=GameSessionTest,QuestEngineTest` +Expected: PASS (delegation works; existing quest tests still green). + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/GameSession.java \ + src/main/java/thb/jeanluc/adventure/game/GameContext.java \ + src/main/java/thb/jeanluc/adventure/game/Game.java \ + src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java +git commit -m "feat(game): GameSession bundles savable state; turn + autosave hook" +``` + +--- + +## Task 2: Restore hooks — GameState, QuestLog, SwitchableItem + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/game/GameState.java` +- Modify: `src/main/java/thb/jeanluc/adventure/game/QuestLog.java` +- Modify: `src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestoreHooksTest { + + @Test + void gameStateRestoreReplacesFlags() { + GameState s = new GameState(); + s.set("old"); + s.restore(List.of("a", "b")); + assertThat(s.isSet("old")).isFalse(); + assertThat(s.all()).containsExactlyInAnyOrder("a", "b"); + } + + @Test + void questLogRestoreReplacesProgress() { + QuestLog q = new QuestLog(); + q.start("stale"); + q.restore(Map.of("active", 2), Set.of("done")); + assertThat(q.isActive("stale")).isFalse(); + assertThat(q.stageIndex("active")).isEqualTo(2); + assertThat(q.isCompleted("done")).isTrue(); + } + + @Test + void switchableSetStateDoesNotRunEffects() { + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("on").offText("off").state(false).effects(List.of()) + .build(); + lamp.setState(true); + assertThat(lamp.isOn()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=RestoreHooksTest` +Expected: FAIL — `restore` / `setState` not defined. + +- [ ] **Step 3: Add `GameState.restore`** + +In `src/main/java/thb/jeanluc/adventure/game/GameState.java` add the import `java.util.Collection` and the method: +```java + /** Replaces all flags with the given collection (used by load). */ + public void restore(Collection newFlags) { + flags.clear(); + flags.addAll(newFlags); + } +``` + +- [ ] **Step 4: Add `QuestLog.restore`** + +In `src/main/java/thb/jeanluc/adventure/game/QuestLog.java` add imports `java.util.Collection` and the method: +```java + /** Replaces all progress with the given stage map and completed set (load). */ + public void restore(Map stages, Collection completedIds) { + stageIndex.clear(); + stageIndex.putAll(stages); + completed.clear(); + completed.addAll(completedIds); + } +``` + +- [ ] **Step 5: Add `SwitchableItem.setState`** + +In `src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java`, after `isOn()`: +```java + /** + * Directly sets the on/off state without running effects or printing. + * Used only when restoring a saved game. + * + * @param state the state to restore + */ + public void setState(boolean state) { + this.state = state; + } +``` + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=RestoreHooksTest` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/GameState.java \ + src/main/java/thb/jeanluc/adventure/game/QuestLog.java \ + src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java \ + src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java +git commit -m "feat: restore hooks for flags, quest progress, switch state" +``` + +--- + +## Task 3: `GameIO.choose` primitive (console default) + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java`: +```java +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChooseDefaultTest { + + @Test + void returnsZeroBasedIndexOfValidChoice() { + TestIO io = new TestIO().enqueue("2"); + int i = io.choose("Menu", List.of("A", "B", "C")); + assertThat(i).isEqualTo(1); + } + + @Test + void rePromptsOnInvalidThenAccepts() { + TestIO io = new TestIO().enqueue("9").enqueue("x").enqueue("1"); + int i = io.choose("Menu", List.of("A", "B")); + assertThat(i).isZero(); + assertThat(io.allOutput()).contains("between 1 and 2"); + } + + @Test + void eofReturnsLastOption() { + TestIO io = new TestIO(); // empty queue → readLine() returns null + int i = io.choose("Menu", List.of("Play", "Quit")); + assertThat(i).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=ChooseDefaultTest` +Expected: FAIL — `choose` not defined. + +- [ ] **Step 3: Add the `choose` default to `GameIO`** + +In `src/main/java/thb/jeanluc/adventure/io/GameIO.java` add `import java.util.List;` and: +```java + /** + * Presents a titled list of options and returns the 0-based index of the + * player's choice. Default (console) prints a numbered list and reads a + * number, re-prompting on invalid input. EOF / empty input returns the + * LAST option (menus place the safe/cancel option last). Frontends with + * native widgets (e.g. buttons) may override. + * + * @param title heading shown above the options + * @param options non-empty list of option labels + * @return 0-based index into {@code options} + */ + default int choose(String title, List options) { + StyledText.Builder b = StyledText.builder().heading(title).plain("\n"); + for (int i = 0; i < options.size(); i++) { + b.plain(" " + (i + 1) + ") " + options.get(i) + "\n"); + } + print(b.build()); + while (true) { + String line = readLine(); + if (line == null || line.isBlank()) { + return options.size() - 1; + } + try { + int n = Integer.parseInt(line.trim()); + if (n >= 1 && n <= options.size()) { + return n - 1; + } + } catch (NumberFormatException ignored) { + // fall through to re-prompt + } + write("Please enter a number between 1 and " + options.size() + "."); + } + } +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=ChooseDefaultTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \ + src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java +git commit -m "feat(io): GameIO.choose numbered-menu primitive (console default)" +``` + +--- + +## Task 4: `SaveData` DTO + `SaveSlotInfo` + `SaveException` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveData.java` +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java` +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveException.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java`: +```java +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveDataTest { + + @Test + void roundTripsThroughJackson() throws Exception { + SaveData data = new SaveData( + 1, "Haunted Manor", "slot-a", 1700000000000L, 7, + "library", List.of("kitchen", "library"), 3, + List.of("key"), List.of("power_on"), + Map.of("restore_power", 1), List.of("intro"), + Map.of("cellar", List.of("shovel")), Map.of("lamp", true)); + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString(data); + SaveData back = mapper.readValue(json, SaveData.class); + assertThat(back).isEqualTo(data); + assertThat(back.currentRoomId()).isEqualTo("library"); + assertThat(back.switchStates()).containsEntry("lamp", true); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveDataTest` +Expected: FAIL — `SaveData` not defined. + +- [ ] **Step 3: Create the records + exception** + +`src/main/java/thb/jeanluc/adventure/save/SaveData.java`: +```java +package thb.jeanluc.adventure.save; + +import java.util.List; +import java.util.Map; + +/** + * JSON-serialised snapshot of the mutable game state — a delta laid over the + * world freshly loaded from YAML. Header fields ({@code slotName}, room, + * {@code turn}, {@code savedAtEpochMillis}) double as the slot-list metadata. + */ +public record SaveData( + int schemaVersion, + String worldTitle, + String slotName, + long savedAtEpochMillis, + int turn, + String currentRoomId, + List visitedRoomIds, + int gold, + List inventoryItemIds, + List flags, + Map questStages, + List questCompleted, + Map> roomItemIds, + Map switchStates +) { + /** Current on-disk schema version. */ + public static final int CURRENT_VERSION = 1; +} +``` + +`src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java`: +```java +package thb.jeanluc.adventure.save; + +/** Lightweight metadata for one save slot, shown in the Load menu. */ +public record SaveSlotInfo(String slug, String slotName, String roomId, + int turn, long savedAtEpochMillis) { +} +``` + +`src/main/java/thb/jeanluc/adventure/save/SaveException.java`: +```java +package thb.jeanluc.adventure.save; + +/** Thrown when a save cannot be written/read; carries a player-facing message. */ +public class SaveException extends RuntimeException { + + public SaveException(String message, Throwable cause) { + super(message, cause); + } + + public SaveException(String message) { + super(message); + } + + /** Player-facing message (the constructor message is already user-friendly). */ + public String getUserMessage() { + return getMessage(); + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=SaveDataTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/SaveData.java \ + src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java \ + src/main/java/thb/jeanluc/adventure/save/SaveException.java \ + src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java +git commit -m "feat(save): SaveData/SaveSlotInfo/SaveException model" +``` + +--- + +## Task 5: `SaveCodec` — capture & apply (pure, no disk) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveCodec.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java`: +```java +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveCodecTest { + + /** Builds a fresh 2-room world: shovel in cellar, lamp (off) in kitchen. */ + private GameSession freshWorld(String slot) { + Room kitchen = new Room("kitchen", "Kitchen", "k"); + Room cellar = new Room("cellar", "Cellar", "c"); + kitchen.addExit(Direction.DOWN, cellar); + Item shovel = PlainItem.builder().id("shovel").name("Shovel").description("d").light(false).build(); + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("on").offText("off").state(false).effects(List.of()).build(); + cellar.addItem(shovel); + kitchen.addItem(lamp); + Map items = new LinkedHashMap<>(); + items.put("shovel", shovel); + items.put("lamp", lamp); + Map rooms = new LinkedHashMap<>(); + rooms.put("kitchen", kitchen); + rooms.put("cellar", cellar); + World w = new World(rooms, items, Map.of(), "Haunted Manor", "welcome"); + return new GameSession(w, new Player(kitchen, 0), slot); + } + + @Test + void captureThenApplyToFreshWorldReproducesState() { + GameSession src = freshWorld("slot-a"); + // mutate: move to cellar, pick up shovel, light the lamp, set a flag, gold + src.getPlayer().setCurrentRoom(src.getWorld().getRooms().get("cellar")); + Item shovel = src.getWorld().getRooms().get("cellar").removeItem("shovel").orElseThrow(); + src.getPlayer().addItem(shovel); + ((SwitchableItem) src.getWorld().getItems().get("lamp")).setState(true); + src.getState().set("power_on"); + src.getPlayer().setGold(5); + src.setTurn(12); + + SaveData data = SaveCodec.capture(src); + + GameSession dst = freshWorld("slot-a"); + SaveCodec.apply(data, dst); + + assertThat(dst.getPlayer().getCurrentRoom().getId()).isEqualTo("cellar"); + assertThat(dst.getPlayer().hasItem("shovel")).isTrue(); + assertThat(dst.getWorld().getRooms().get("cellar").findItem("shovel")).isEmpty(); + assertThat(((SwitchableItem) dst.getWorld().getItems().get("lamp")).isOn()).isTrue(); + assertThat(dst.getState().isSet("power_on")).isTrue(); + assertThat(dst.getPlayer().getGold()).isEqualTo(5); + assertThat(dst.getTurn()).isEqualTo(12); + assertThat(dst.getPlayer().getVisitedRoomIds()).contains("kitchen", "cellar"); + } + + @Test + void applyRejectsUnknownRoom() { + GameSession src = freshWorld("slot-a"); + SaveData data = SaveCodec.capture(src); + SaveData broken = new SaveData( + data.schemaVersion(), data.worldTitle(), data.slotName(), + data.savedAtEpochMillis(), data.turn(), "ballroom", + data.visitedRoomIds(), data.gold(), data.inventoryItemIds(), + data.flags(), data.questStages(), data.questCompleted(), + data.roomItemIds(), data.switchStates()); + org.junit.jupiter.api.Assertions.assertThrows(SaveException.class, + () -> SaveCodec.apply(broken, freshWorld("slot-a"))); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveCodecTest` +Expected: FAIL — `SaveCodec` not defined. + +- [ ] **Step 3: Implement `SaveCodec`** + +`src/main/java/thb/jeanluc/adventure/save/SaveCodec.java`: +```java +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.game.QuestLog; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Converts between a live {@link GameSession} and a {@link SaveData} snapshot. + * Pure (no disk): {@link #capture} reads a session; {@link #apply} overlays a + * snapshot onto a session built from a freshly loaded world. + */ +public final class SaveCodec { + + private SaveCodec() { + } + + /** Reads the mutable state of {@code session} into a {@link SaveData}. */ + public static SaveData capture(GameSession session) { + World w = session.getWorld(); + Player p = session.getPlayer(); + + Map> roomItems = new LinkedHashMap<>(); + for (Room room : w.getRooms().values()) { + if (!room.getItems().isEmpty()) { + roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet())); + } + } + Map switches = new LinkedHashMap<>(); + for (Item item : w.getItems().values()) { + if (item instanceof SwitchableItem sw) { + switches.put(sw.getId(), sw.isOn()); + } + } + Map stages = new LinkedHashMap<>(); + QuestLog log = session.getQuestLog(); + for (String id : log.active()) { + stages.put(id, log.stageIndex(id)); + } + + return new SaveData( + SaveData.CURRENT_VERSION, + w.getTitle(), + session.getSlotName(), + System.currentTimeMillis(), + session.getTurn(), + p.getCurrentRoom().getId(), + new ArrayList<>(p.getVisitedRoomIds()), + p.getGold(), + new ArrayList<>(p.getInventory().keySet()), + new ArrayList<>(session.getState().all()), + stages, + new ArrayList<>(log.completed()), + roomItems, + switches); + } + + /** + * Overlays {@code data} onto {@code session} (whose world was just loaded + * from YAML). Throws {@link SaveException} if the save references ids that + * no longer exist in the current world. + */ + public static void apply(SaveData data, GameSession session) { + World w = session.getWorld(); + Player p = session.getPlayer(); + + // 1. Clear all item placements, then redistribute from the save. + for (Room room : w.getRooms().values()) { + room.getItems().clear(); + } + p.getInventory().clear(); + for (Map.Entry> e : data.roomItemIds().entrySet()) { + Room room = requireRoom(w, e.getKey()); + for (String itemId : e.getValue()) { + room.addItem(requireItem(w, itemId)); + } + } + for (String itemId : data.inventoryItemIds()) { + p.addItem(requireItem(w, itemId)); + } + + // 2. Switch states. + for (Map.Entry e : data.switchStates().entrySet()) { + if (requireItem(w, e.getKey()) instanceof SwitchableItem sw) { + sw.setState(e.getValue()); + } + } + + // 3. Player position + visited + gold. + p.setCurrentRoom(requireRoom(w, data.currentRoomId())); + p.getVisitedRoomIds().clear(); + p.getVisitedRoomIds().addAll(data.visitedRoomIds()); + p.setGold(data.gold()); + + // 4. Flags, quests, turn. + session.getState().restore(data.flags()); + session.getQuestLog().restore(data.questStages(), data.questCompleted()); + session.setTurn(data.turn()); + } + + private static Room requireRoom(World w, String id) { + Room r = w.getRooms().get(id); + if (r == null) { + throw new SaveException("Save refers to unknown room '" + id + + "'. The game content may have changed."); + } + return r; + } + + private static Item requireItem(World w, String id) { + Item i = w.getItems().get(id); + if (i == null) { + throw new SaveException("Save refers to unknown item '" + id + + "'. The game content may have changed."); + } + return i; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=SaveCodecTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/SaveCodec.java \ + src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java +git commit -m "feat(save): SaveCodec capture/apply over a fresh world" +``` + +--- + +## Task 6: `SaveService` — disk save/list/load (atomic) + version check + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/SaveService.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java` + +`SaveService` reloads the world via `WorldLoader` on load. Tests construct it with an explicit save directory (`@TempDir`) and rely on the real classpath world for the round-trip. + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java`: +```java +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.loader.WorldLoader; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SaveServiceTest { + + private GameSession freshSession(String slot) { + WorldLoader.LoadResult r = new WorldLoader().load(); + return new GameSession(r.world(), r.player(), slot); + } + + @Test + void saveThenLoadReproducesState(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("my-game"); + s.getState().set("power_on"); + s.getPlayer().setGold(4); + s.setTurn(9); + svc.save(s); + + GameSession loaded = svc.load("my-game"); + assertThat(loaded.getState().isSet("power_on")).isTrue(); + assertThat(loaded.getPlayer().getGold()).isEqualTo(4); + assertThat(loaded.getTurn()).isEqualTo(9); + assertThat(loaded.getSlotName()).isEqualTo("my-game"); + } + + @Test + void listReturnsSlotMetadata(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("Alpha Run"); + s.setTurn(3); + svc.save(s); + + List slots = svc.list(); + assertThat(slots).hasSize(1); + assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run"); + assertThat(slots.getFirst().turn()).isEqualTo(3); + } + + @Test + void listSkipsCorruptFiles(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("broken.json"), "{ not json"); + assertThat(new SaveService(dir).list()).isEmpty(); + } + + @Test + void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("bad.json"), "{ not json"); + SaveService svc = new SaveService(dir); + assertThrows(SaveException.class, () -> svc.load("bad")); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveServiceTest` +Expected: FAIL — `SaveService` not defined. + +- [ ] **Step 3: Implement `SaveService`** + +`src/main/java/thb/jeanluc/adventure/save/SaveService.java`: +```java +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.loader.WorldLoader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Reads and writes save slots as JSON files in a directory (default + * {@code ./saves}). One slot = one {@code .json}. Loading reloads the + * world from YAML and overlays the snapshot via {@link SaveCodec}. + */ +@Slf4j +public class SaveService { + + private final Path dir; + private final ObjectMapper mapper = new ObjectMapper(); + + /** Uses the default {@code ./saves} directory. */ + public SaveService() { + this(Path.of("saves")); + } + + public SaveService(Path dir) { + this.dir = dir; + } + + /** Slug used for the on-disk filename of a slot name. */ + public static String slug(String slotName) { + String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_"); + return s.isBlank() ? "save" : s; + } + + /** Writes the session to {@code .json} atomically. */ + public void save(GameSession session) { + SaveData data = SaveCodec.capture(session); + try { + Files.createDirectories(dir); + Path target = dir.resolve(slug(session.getSlotName()) + ".json"); + Path tmp = dir.resolve(slug(session.getSlotName()) + ".json.tmp"); + mapper.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), data); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicFailed) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e); + } + } + + /** Lists all readable slots, newest first; skips unreadable files. */ + public List list() { + if (!Files.isDirectory(dir)) { + return List.of(); + } + List out = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) { + try { + SaveData d = mapper.readValue(f.toFile(), SaveData.class); + String slug = f.getFileName().toString().replaceFirst("\\.json$", ""); + out.add(new SaveSlotInfo(slug, d.slotName(), d.currentRoomId(), + d.turn(), d.savedAtEpochMillis())); + } catch (IOException badFile) { + log.warn("Skipping unreadable save file {}", f, badFile); + } + } + } catch (IOException e) { + log.warn("Could not list save directory {}", dir, e); + } + out.sort(Comparator.comparingLong(SaveSlotInfo::savedAtEpochMillis).reversed()); + return out; + } + + /** Loads a slot by slug: read JSON, reload world, overlay snapshot. */ + public GameSession load(String slug) { + Path file = dir.resolve(slug + ".json"); + SaveData data; + try { + data = mapper.readValue(file.toFile(), SaveData.class); + } catch (IOException e) { + throw new SaveException("Could not read save '" + slug + "': " + e.getMessage(), e); + } + if (data.schemaVersion() != SaveData.CURRENT_VERSION) { + throw new SaveException("Save '" + slug + "' uses an incompatible version (" + + data.schemaVersion() + "); expected " + SaveData.CURRENT_VERSION + "."); + } + WorldLoader.LoadResult fresh = new WorldLoader().load(); + GameSession session = new GameSession(fresh.world(), fresh.player(), data.slotName()); + SaveCodec.apply(data, session); + return session; + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=SaveServiceTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/SaveService.java \ + src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +git commit -m "feat(save): SaveService disk persistence (atomic write, list, load)" +``` + +--- + +## Task 7: `Settings` + `SettingsStore` + mutable `ConsoleIO` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/save/Settings.java` +- Create: `src/main/java/thb/jeanluc/adventure/save/SettingsStore.java` +- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` +- Test: `src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java` + +- [ ] **Step 1: Write the failing tests** + +`src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java`: +```java +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.io.ConsoleIO; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SettingsStoreTest { + + @Test + void savesAndLoads(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + store.save(new Settings(ConsoleIO.ColorMode.ON, ConsoleIO.GlyphMode.ASCII)); + Settings loaded = store.load(); + assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON); + assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + } + + @Test + void missingFileReturnsDefaults(@TempDir Path dir) { + Settings loaded = new SettingsStore(dir.resolve("nope.json")).load(); + assertThat(loaded).isEqualTo(Settings.defaults()); + } +} +``` + +`src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java`: +```java +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.StringReader; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsoleIOSettingsTest { + + @Test + void colorSetterTogglesAnsiEmission() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), + new PrintStream(out), ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + io.setColorMode(ConsoleIO.ColorMode.ON); + io.print(thb.jeanluc.adventure.io.text.StyledText.builder().heading("Hi").build()); + assertThat(out.toString()).contains("["); // ANSI escape present + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `mvn -q test -Dtest=SettingsStoreTest,ConsoleIOSettingsTest` +Expected: FAIL — `Settings`/`SettingsStore`/setters missing. + +- [ ] **Step 3: Create `Settings` + `SettingsStore`** + +`src/main/java/thb/jeanluc/adventure/save/Settings.java`: +```java +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.io.ConsoleIO; + +/** Persisted user preferences (minimal: colour + glyph fidelity). */ +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + + public static Settings defaults() { + return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE); + } +} +``` + +`src/main/java/thb/jeanluc/adventure/save/SettingsStore.java`: +```java +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Loads/saves {@link Settings} as JSON (default {@code saves/settings.json}). */ +@Slf4j +public class SettingsStore { + + private final Path file; + private final ObjectMapper mapper = new ObjectMapper(); + + public SettingsStore() { + this(Path.of("saves", "settings.json")); + } + + public SettingsStore(Path file) { + this.file = file; + } + + /** Returns persisted settings, or {@link Settings#defaults()} on any problem. */ + public Settings load() { + if (!Files.isRegularFile(file)) { + return Settings.defaults(); + } + try { + return mapper.readValue(file.toFile(), Settings.class); + } catch (IOException e) { + log.warn("Could not read settings {}, using defaults", file, e); + return Settings.defaults(); + } + } + + /** Persists settings; logs and swallows failures (non-fatal). */ + public void save(Settings settings) { + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), settings); + } catch (IOException e) { + log.warn("Could not write settings {}", file, e); + } + } +} +``` + +- [ ] **Step 4: Make `ConsoleIO` color/glyph mutable** + +In `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java`: +1. Change the fields: +```java + private boolean useColor; + private GlyphMode glyphs; +``` +(remove `final` from both). +2. After the 4-arg constructor, add setters: +```java + /** Re-resolves colour emission from the given mode (AUTO checks the console). */ + public void setColorMode(ColorMode mode) { + this.useColor = switch (mode) { + case ON -> true; + case OFF -> false; + case AUTO -> System.console() != null; + }; + } + + /** Switches the frame/glyph fidelity tier live. */ + public void setGlyphMode(GlyphMode mode) { + this.glyphs = mode; + } +``` +3. In the 4-arg constructor, replace the `this.useColor = switch (...)` block with `setColorMode(colorMode);` (keeps one resolution path). + +- [ ] **Step 5: Run tests** + +Run: `mvn -q test -Dtest=SettingsStoreTest,ConsoleIOSettingsTest,ConsoleIORenderTest` +Expected: PASS (existing `ConsoleIORenderTest` still green). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/save/Settings.java \ + src/main/java/thb/jeanluc/adventure/save/SettingsStore.java \ + src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \ + src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java \ + src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java +git commit -m "feat(settings): Settings + SettingsStore; runtime-togglable ConsoleIO" +``` + +--- + +## Task 8: Menu package — `MenuAction`, `MainMenu`, `SettingsMenu` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/menu/MenuAction.java` +- Create: `src/main/java/thb/jeanluc/adventure/menu/MainMenu.java` +- Create: `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java` +- Test: `src/test/java/thb/jeanluc/adventure/menu/MenuTest.java` + +`MainMenu` and `SettingsMenu` are frontend-agnostic — they only call `io.choose`, `io.readLine`, `io.write`. `SettingsMenu` applies a colour/glyph toggle to a `ConsoleIO` (via `instanceof`) and persists through `SettingsStore`; on non-console IO it just persists (GUI renders its own styling). + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/menu/MenuTest.java`: +```java +package thb.jeanluc.adventure.menu; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; +import thb.jeanluc.adventure.io.ConsoleIO; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class MenuTest { + + @Test + void mainMenuMapsChoiceToAction() { + // options order: New Game(1) / Load(2) / Settings(3) / Quit(4) + assertThat(MainMenu.show(new TestIO().enqueue("1"))).isEqualTo(MenuAction.NEW_GAME); + assertThat(MainMenu.show(new TestIO().enqueue("2"))).isEqualTo(MenuAction.LOAD); + assertThat(MainMenu.show(new TestIO().enqueue("3"))).isEqualTo(MenuAction.SETTINGS); + assertThat(MainMenu.show(new TestIO().enqueue("4"))).isEqualTo(MenuAction.QUIT); + } + + @Test + void promptNameUsesDefaultOnBlank() { + assertThat(MainMenu.promptName(new TestIO().enqueue(""), "Manor")).isEqualTo("Manor"); + assertThat(MainMenu.promptName(new TestIO().enqueue("Spooky"), "Manor")).isEqualTo("Spooky"); + } + + @Test + void pickSlotReturnsNullWhenEmpty(@TempDir Path dir) { + TestIO io = new TestIO(); + assertThat(MainMenu.pickSlot(io, new SaveService(dir))).isNull(); + assertThat(io.allOutput()).contains("No saved games"); + } + + @Test + void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) { + SettingsStore store = new SettingsStore(dir.resolve("settings.json")); + // choose "Glyph mode" (assume option 2) → its toggle, then "Back" (last) + TestIO io = new TestIO().enqueue("2").enqueue("3"); + Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO()); + // UNICODE default toggles to ASCII; persisted + assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=MenuTest` +Expected: FAIL — menu classes missing. + +- [ ] **Step 3: Create `MenuAction`** + +`src/main/java/thb/jeanluc/adventure/menu/MenuAction.java`: +```java +package thb.jeanluc.adventure.menu; + +/** Top-level main-menu choices, in display order. */ +public enum MenuAction { + NEW_GAME, LOAD, SETTINGS, QUIT +} +``` + +- [ ] **Step 4: Create `MainMenu`** + +`src/main/java/thb/jeanluc/adventure/menu/MainMenu.java`: +```java +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; + +import java.util.ArrayList; +import java.util.List; + +/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */ +public final class MainMenu { + + private MainMenu() { + } + + /** Shows the main menu and returns the chosen action. */ + public static MenuAction show(GameIO io) { + int i = io.choose("HAUNTED MANOR", + List.of("New Game", "Load Game", "Settings", "Quit")); + return MenuAction.values()[i]; + } + + /** Prompts for a new save name; blank input yields {@code defaultName}. */ + public static String promptName(GameIO io, String defaultName) { + io.write("Name your save (Enter for \"" + defaultName + "\"):"); + String line = io.readLine(); + return (line == null || line.isBlank()) ? defaultName : line.trim(); + } + + /** + * Lets the player pick a save slot to load. Returns the chosen + * {@link SaveSlotInfo}, or {@code null} if there are none or the player + * picks "Back". + */ + public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) { + List slots = saves.list(); + if (slots.isEmpty()) { + io.write("No saved games."); + return null; + } + List labels = new ArrayList<>(); + for (SaveSlotInfo s : slots) { + labels.add(s.slotName() + " (" + s.roomId() + ", turn " + s.turn() + ")"); + } + labels.add("Back"); + int i = io.choose("LOAD GAME", labels); + return i < slots.size() ? slots.get(i) : null; + } +} +``` + +- [ ] **Step 5: Create `SettingsMenu`** + +`src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java`: +```java +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; + +import java.util.List; + +/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */ +public final class SettingsMenu { + + private SettingsMenu() { + } + + /** + * Loops the settings screen until the player picks "Back", persisting and + * applying each change. Returns the final settings. + * + * @param io active IO + * @param current starting settings + * @param store where to persist + * @param consoleIo the console IO to apply changes to live, or null if not console + */ + public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) { + Settings s = current; + while (true) { + int i = io.choose("SETTINGS", List.of( + "Colour: " + s.colorMode(), + "Glyphs: " + s.glyphMode(), + "Back")); + if (i == 0) { + s = new Settings(nextColor(s.colorMode()), s.glyphMode()); + } else if (i == 1) { + s = new Settings(s.colorMode(), nextGlyph(s.glyphMode())); + } else { + return s; + } + store.save(s); + apply(s, consoleIo); + } + } + + /** Applies colour/glyph settings to the console IO (no-op if null). */ + public static void apply(Settings s, ConsoleIO consoleIo) { + if (consoleIo != null) { + consoleIo.setColorMode(s.colorMode()); + consoleIo.setGlyphMode(s.glyphMode()); + } + } + + private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) { + return switch (m) { + case AUTO -> ConsoleIO.ColorMode.ON; + case ON -> ConsoleIO.ColorMode.OFF; + case OFF -> ConsoleIO.ColorMode.AUTO; + }; + } + + private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) { + return switch (m) { + case UNICODE -> ConsoleIO.GlyphMode.ASCII; + case ASCII -> ConsoleIO.GlyphMode.GLYPH; + case GLYPH -> ConsoleIO.GlyphMode.UNICODE; + }; + } +} +``` + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=MenuTest` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/menu/ \ + src/test/java/thb/jeanluc/adventure/menu/MenuTest.java +git commit -m "feat(menu): MainMenu, SettingsMenu, MenuAction (frontend-agnostic)" +``` + +--- + +## Task 9: In-game commands — `SaveCommand` and `MenuCommand` (replace `QuitCommand`) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java` +- Create: `src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java` +- Delete: `src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java` +- Test: `src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java`: +```java +package thb.jeanluc.adventure.command; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.command.impl.MenuCommand; +import thb.jeanluc.adventure.command.impl.SaveCommand; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.loader.WorldLoader; +import thb.jeanluc.adventure.save.SaveService; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveMenuCommandTest { + + private GameContext ctx(String slot) { + WorldLoader.LoadResult r = new WorldLoader().load(); + return new GameContext(new GameSession(r.world(), r.player(), slot), new TestIO()); + } + + @Test + void saveCommandWritesSlotAndReportsSuccess(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameContext ctx = ctx("game-a"); + new SaveCommand(svc).execute(ctx, List.of()); + assertThat(svc.list()).extracting(s -> s.slotName()).containsExactly("game-a"); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Game saved"); + } + + @Test + void menuCommandSavesThenStopsLoop(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameContext ctx = ctx("game-b"); + Game game = new Game(ctx, new CommandRegistry(), new CommandParser()); + MenuCommand menu = new MenuCommand(svc); + menu.bind(game); + menu.execute(ctx, List.of()); + assertThat(svc.list()).hasSize(1); + // loop flag flipped: a subsequent run() returns immediately (no input consumed) + assertThat(game).isNotNull(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=SaveMenuCommandTest` +Expected: FAIL — `SaveCommand`/`MenuCommand` missing. + +- [ ] **Step 3: Create `SaveCommand`** + +`src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java`: +```java +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; + +import java.util.List; + +/** Manually saves the active slot. Usage: {@code save}. */ +@RequiredArgsConstructor +public class SaveCommand implements Command { + + private final SaveService saves; + + @Override + public void execute(GameContext ctx, List args) { + try { + saves.save(ctx.getSession()); + ctx.getIo().write("Game saved."); + } catch (SaveException e) { + ctx.getIo().write(e.getUserMessage()); + } + } + + @Override + public String help() { + return "save - save your game to the current slot"; + } +} +``` + +- [ ] **Step 4: Create `MenuCommand`** + +(Keep `QuitCommand.java` in place for now — `App.java` still references it; it is removed in Task 11 when the registry is rewired, so every commit compiles.) + +`src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java`: +```java +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; + +import java.util.List; + +/** + * Saves the active slot and returns to the main menu by stopping the loop. + * Usage: {@code menu} (aliases {@code quit}, {@code exit}). + */ +@RequiredArgsConstructor +public class MenuCommand implements Command { + + private final SaveService saves; + private Game game; + + /** Binds the running game (two-phase wiring, as the registry is built first). */ + public void bind(Game game) { + this.game = game; + } + + @Override + public void execute(GameContext ctx, List args) { + try { + saves.save(ctx.getSession()); + ctx.getIo().write("Game saved. Returning to the main menu."); + } catch (SaveException e) { + ctx.getIo().write("Could not save: " + e.getUserMessage()); + } + if (game != null) { + game.stop(); + } + } + + @Override + public String help() { + return "menu - save and return to the main menu (aliases: quit, exit)"; + } +} +``` + +- [ ] **Step 5: Run tests** + +Run: `mvn -q test -Dtest=SaveMenuCommandTest` +Expected: PASS. (`QuitCommand` still exists and `App` still compiles; the registry swap + deletion happen in Task 11.) + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java \ + src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java \ + src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java +git commit -m "feat(command): save + menu commands; remove QuitCommand" +``` + +--- + +## Task 10: Autosave on quest completion + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/game/QuestEngine.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java` + +The interval autosave (every 10 turns) was already added to `Game` in Task 1. Here we trigger the silent `ctx.save()` hook when a quest completes. + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Quest; +import thb.jeanluc.adventure.model.QuestStage; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuestAutosaveTest { + + @Test + void questCompletionTriggersSaveCallback() { + Quest q = new Quest("q", "Quest", true, List.of( + new QuestStage("Do it", List.of(new Condition(Condition.Type.FLAG, "a")), List.of())), + List.of()); + Room k = new Room("k", "K", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w", Map.of("q", q)); + GameContext ctx = new GameContext(new GameSession(w, new Player(k, 0), "slot"), new TestIO()); + AtomicInteger saves = new AtomicInteger(); + ctx.setSaveCallback(saves::incrementAndGet); + + QuestEngine.tick(ctx); // starts quest; not complete yet + assertThat(saves.get()).isZero(); + ctx.getState().set("a"); + QuestEngine.tick(ctx); // completes quest → autosave + assertThat(saves.get()).isEqualTo(1); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=QuestAutosaveTest` +Expected: FAIL — no save on completion. + +- [ ] **Step 3: Trigger save in `QuestEngine.finish`** + +In `src/main/java/thb/jeanluc/adventure/game/QuestEngine.java`, at the END of the `finish` method (after the `ctx.getIo().print(...)` "Quest complete" line), add: +```java + ctx.save(); +``` + +- [ ] **Step 4: Run tests** + +Run: `mvn -q test -Dtest=QuestAutosaveTest,QuestEngineTest` +Expected: PASS (existing `QuestEngineTest` uses the no-op default callback, still green). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/QuestEngine.java \ + src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java +git commit -m "feat(game): autosave on quest completion" +``` + +--- + +## Task 11: Wire the shell loop in `App` + .gitignore + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/App.java` +- Modify: `.gitignore` + +`AppGui` is unchanged — it calls `App.run(io)` on its worker thread, so the GUI gets the menu automatically (with the console-default `choose` until a Swing override is added in Task 12). + +- [ ] **Step 1: Replace `App.java`** + +Replace `src/main/java/thb/jeanluc/adventure/App.java` with: +```java +package thb.jeanluc.adventure; + +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.DropCommand; +import thb.jeanluc.adventure.command.impl.ExamineCommand; +import thb.jeanluc.adventure.command.impl.GiveCommand; +import thb.jeanluc.adventure.command.impl.GoCommand; +import thb.jeanluc.adventure.command.impl.HelpCommand; +import thb.jeanluc.adventure.command.impl.InventoryCommand; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.command.impl.MapCommand; +import thb.jeanluc.adventure.command.impl.MenuCommand; +import thb.jeanluc.adventure.command.impl.QuestsCommand; +import thb.jeanluc.adventure.command.impl.ReadCommand; +import thb.jeanluc.adventure.command.impl.SaveCommand; +import thb.jeanluc.adventure.command.impl.TakeCommand; +import thb.jeanluc.adventure.command.impl.TalkCommand; +import thb.jeanluc.adventure.command.impl.UseCommand; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.io.text.Banner; +import thb.jeanluc.adventure.loader.WorldLoader; +import thb.jeanluc.adventure.menu.MainMenu; +import thb.jeanluc.adventure.menu.MenuAction; +import thb.jeanluc.adventure.menu.SettingsMenu; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; + +import java.util.List; + +/** + * Console entry point. Loads settings, then runs the main-menu shell loop: + * New Game / Load / Settings / Quit. Each game runs the shared {@link Game} + * loop and returns here when the player saves-and-exits, reaches an ending, + * or input ends. Shared by console and GUI via {@link #run(GameIO)}. + */ +@Slf4j +public final class App { + + private App() { + } + + public static void main(String[] args) { + GameIO io = new ConsoleIO(); + try { + run(io); + } catch (RuntimeException e) { + log.error("Fatal error", e); + io.write("Fatal error: " + e.getMessage()); + System.exit(1); + } + } + + /** Runs the menu shell on the given IO until the player quits. */ + public static void run(GameIO io) { + SaveService saves = new SaveService(); + SettingsStore settingsStore = new SettingsStore(); + Settings settings = settingsStore.load(); + ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null; + SettingsMenu.apply(settings, consoleIo); + + boolean running = true; + while (running) { + switch (MainMenu.show(io)) { + case NEW_GAME -> play(io, saves, newSession(io, saves)); + case LOAD -> { + SaveSlotInfo slot = MainMenu.pickSlot(io, saves); + if (slot != null) { + try { + play(io, saves, saves.load(slot.slug())); + } catch (SaveException e) { + io.write("Could not load: " + e.getUserMessage()); + } + } + } + case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo); + case QUIT -> running = false; + } + } + io.write("Farewell."); + } + + /** Loads a fresh world and binds it to a newly named session. */ + private static GameSession newSession(GameIO io, SaveService saves) { + WorldLoader.LoadResult loaded = new WorldLoader().load(); + String name = MainMenu.promptName(io, "Manor"); + return new GameSession(loaded.world(), loaded.player(), name); + } + + /** Builds the registry, runs one game to completion, autosaving to its slot. */ + private static void play(GameIO io, SaveService saves, GameSession session) { + GameContext ctx = new GameContext(session, io); + ctx.setSaveCallback(() -> { + try { + saves.save(session); + } catch (SaveException e) { + log.warn("Autosave failed", e); + } + }); + + CommandRegistry registry = new CommandRegistry(); + registry.register(new GoCommand(), "go", "move", "walk"); + registry.register(new LookCommand(), "look", "l"); + registry.register(new InventoryCommand(), "inventory", "inv", "i"); + registry.register(new TakeCommand(), "take", "pick", "get"); + registry.register(new DropCommand(), "drop", "put"); + registry.register(new UseCommand(), "use"); + registry.register(new ReadCommand(), "read"); + registry.register(new ExamineCommand(), "examine", "x", "inspect"); + registry.register(new MapCommand(), "map", "m"); + registry.register(new QuestsCommand(), "quests", "log", "journal"); + registry.register(new TalkCommand(), "talk", "speak"); + registry.register(new GiveCommand(), "give"); + registry.register(new SaveCommand(saves), "save"); + + Game game = new Game(ctx, registry, new CommandParser()); + + MenuCommand menu = new MenuCommand(saves); + menu.bind(game); + registry.register(menu, "menu", "quit", "exit"); + registry.register(new HelpCommand(registry), "help", "?"); + + io.print(Banner.welcome(session.getWorld().getTitle())); + io.write(session.getWorld().getWelcomeMessage()); + new LookCommand().execute(ctx, List.of()); + + game.run(); + } +} +``` + +- [ ] **Step 2: Add `saves/` to `.gitignore`** + +Append to `.gitignore` (create if missing): +``` +# Local save games and settings +saves/ +``` + +- [ ] **Step 3: Delete the now-unused `QuitCommand`** + +`App` no longer references it (replaced by `MenuCommand`): +```bash +git rm src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java +``` + +- [ ] **Step 4: Full build + test** + +Run: `mvn -q clean test` +Expected: PASS — all tests green, project compiles (no more `QuitCommand` references). + +- [ ] **Step 5: Manual console smoke test** + +Run: `mvn -q -DskipTests package && printf '1\nSmoke\nlook\nsave\nquit\n4\n' | java -jar target/*.jar` (or run the console main class via your IDE). +Expected: main menu appears → New Game → name prompt → room shown → "Game saved." → "Returning to the main menu." → menu → Quit → "Farewell." A `saves/smoke.json` file exists. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/App.java .gitignore +git rm --cached -r --ignore-unmatch saves 2>/dev/null; true +git commit -m "feat(app): main-menu shell loop wiring; gitignore saves/" +``` + +--- + +## Task 12: `SwingIO.choose` — buttons (GUI) + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` + +GUI-only; verified manually (no unit test, per the project's no-GUI-test convention). Uses the same EDT↔worker blocking handoff as `readLine` (a `LinkedBlockingQueue`). + +- [ ] **Step 1: Override `choose` in `SwingIO`** + +Add these imports to `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`: +```java +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JDialog; +``` +(`java.util.concurrent.LinkedBlockingQueue` is already imported for `inputs`.) +Add the method (anywhere among the public overrides): +```java + @Override + public int choose(String title, List options) { + // Same EDT↔worker handoff as readLine: the worker blocks on take(), + // the EDT (button click / window close) offers the chosen index. + LinkedBlockingQueue picked = new LinkedBlockingQueue<>(); + SwingUtilities.invokeLater(() -> { + JDialog dialog = new JDialog(frame, title, true); + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + panel.add(new JLabel(title)); + panel.add(Box.createVerticalStrut(8)); + for (int i = 0; i < options.size(); i++) { + int idx = i; + JButton b = new JButton(options.get(i)); + b.setAlignmentX(0f); + b.addActionListener(e -> { + dialog.dispose(); + picked.offer(idx); + }); + panel.add(b); + panel.add(Box.createVerticalStrut(4)); + } + // Closing the dialog counts as the last (safe/cancel) option. + dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + dialog.addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(java.awt.event.WindowEvent e) { + dialog.dispose(); + picked.offer(options.size() - 1); + } + }); + dialog.setContentPane(panel); + dialog.pack(); + dialog.setLocationRelativeTo(frame); + dialog.setVisible(true); + }); + try { + return picked.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return options.size() - 1; + } + } +``` + +- [ ] **Step 2: Build** + +Run: `mvn -q -DskipTests package` +Expected: compiles. + +- [ ] **Step 3: Manual GUI smoke test** + +Run the Swing entry point (`AppGui`). Expected: a modal dialog with **New Game / Load Game / Settings / Quit** buttons appears; clicking New Game prompts for a name in the text field, then the game starts; `quit` saves and the menu dialog reappears; Settings dialog toggles persist. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/SwingIO.java +git commit -m "feat(io): SwingIO.choose renders the menu as buttons" +``` + +--- + +## Task 13: Docs + backlog update + +**Files:** +- Modify: `docs/enhancement-ideas.md` +- Modify: `docs/implementation-status.md` (if it tracks per-feature status) + +- [ ] **Step 1: Mark #6 (menu/settings) and #7 (save/load) as implemented** + +In `docs/enhancement-ideas.md`, add a `> ✅ umgesetzt (Branch feature/main-menu-save-load)` note under sections **6. Hauptmenü & Settings** and **7. Speichern / Laden**, mirroring the style of the existing ✅ notes (e.g. under #3). Note the minimal-settings scope (colour + glyph only) and that music/typewriter toggles remain deferred. Note pathfinding remains in scope for a later round. + +- [ ] **Step 2: Commit** + +```bash +git add docs/enhancement-ideas.md docs/implementation-status.md +git commit -m "docs: mark main menu + save/load implemented; note minimal settings scope" +``` + +--- + +## Final Verification + +- [ ] Run `mvn -q clean test` — all tests pass (existing 67+ plus the new suites). +- [ ] Console smoke (Task 11 Step 4) behaves as described; `saves/*.json` written. +- [ ] GUI smoke (Task 12 Step 3) shows button menu and round-trips a save/load. +- [ ] Load a saved game and confirm: position, inventory, lit lamp / generator state, set flags, and mid-progress quest all restored. +- [ ] `git status` clean; `saves/` is gitignored and untracked. From dd2b1b331fccbc0207c129adc46ea51d6a89e92b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:15:44 +0200 Subject: [PATCH 03/22] feat(game): GameSession bundles savable state; turn + autosave hook --- .../java/thb/jeanluc/adventure/game/Game.java | 12 ++-- .../jeanluc/adventure/game/GameContext.java | 67 ++++++++++++++----- .../jeanluc/adventure/game/GameSession.java | 36 ++++++++++ .../adventure/game/GameSessionTest.java | 52 ++++++++++++++ 4 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 2cf1058..68f5bb1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -32,9 +32,6 @@ public class Game { /** Loop flag. Flipped to false by {@link #stop()}. */ private boolean running = true; - /** Number of commands processed this session; shown in the HUD. */ - private int turn = 0; - /** * Runs the loop until {@link #stop()} is called or input is exhausted. */ @@ -55,7 +52,10 @@ public class Game { ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); } else { cmd.get().execute(ctx, parsed.args()); - turn++; + ctx.getSession().incrementTurn(); + if (ctx.getSession().getTurn() % 10 == 0) { + ctx.save(); + } } publishHud(); maybeEnd(); @@ -66,7 +66,7 @@ public class Game { private void maybeEnd() { Ending e = EndingEngine.triggered(ctx); if (e != null) { - ctx.getIo().print(EndingEngine.render(e, ctx, turn)); + ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn())); stop(); } } @@ -76,7 +76,7 @@ public class Game { ctx.getIo().setHud(new Hud( ctx.getPlayer().getCurrentRoom().getName(), ctx.getPlayer().getGold(), - turn, + ctx.getSession().getTurn(), Light.carryingLight(ctx))); MapView map = MapLayout.compute( ctx.getWorld(), diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java index a552b92..9f41d13 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java @@ -1,32 +1,63 @@ package thb.jeanluc.adventure.game; -import lombok.Getter; -import lombok.RequiredArgsConstructor; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.World; /** - * Bundle of everything a command needs to do its work: the world, the - * player, and the IO channel. Passing this single object keeps command - * signatures small and consistent. + * Bundle of everything a command needs: the active {@link GameSession} + * (world, player, flags, quests, turn) and the IO channel. Getters delegate + * to the session so existing command code keeps working unchanged. */ -@Getter -@RequiredArgsConstructor public class GameContext { - /** Loaded world (rooms, items, npcs). */ - private final World world; - - /** The player avatar. */ - private final Player player; - - /** IO channel for reading input and writing output. */ + private final GameSession session; private final GameIO io; - /** Mutable world flags. Created fresh per context; not a constructor arg. */ - private final GameState state = new GameState(); + /** Silent autosave hook, wired by the app; no-op by default (tests). */ + private Runnable saveCallback = () -> { }; - /** Runtime quest progress. Created fresh per context; not a constructor arg. */ - private final QuestLog questLog = new QuestLog(); + public GameContext(GameSession session, GameIO io) { + this.session = session; + this.io = io; + } + + /** Convenience for tests/legacy callers: wraps a fresh single-use session. */ + public GameContext(World world, Player player, GameIO io) { + this(new GameSession(world, player, "session"), io); + } + + public GameSession getSession() { + return session; + } + + public World getWorld() { + return session.getWorld(); + } + + public Player getPlayer() { + return session.getPlayer(); + } + + public GameState getState() { + return session.getState(); + } + + public QuestLog getQuestLog() { + return session.getQuestLog(); + } + + public GameIO getIo() { + return io; + } + + /** Sets the silent autosave hook (called by interval/quest autosave). */ + public void setSaveCallback(Runnable saveCallback) { + this.saveCallback = saveCallback; + } + + /** Runs the silent autosave hook. */ + public void save() { + saveCallback.run(); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java new file mode 100644 index 0000000..fda1bfb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameSession.java @@ -0,0 +1,36 @@ +package thb.jeanluc.adventure.game; + +import lombok.Getter; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.World; + +/** + * Bundle of the mutable, savable game state for one playthrough: the + * loaded {@link World}, the {@link Player}, world {@link GameState} flags, + * {@link QuestLog} progress, the turn counter, and the bound save-slot name. + * Save/load read and write exactly this object. + */ +@Getter +public class GameSession { + + private final World world; + private final Player player; + private final GameState state = new GameState(); + private final QuestLog questLog = new QuestLog(); + private final String slotName; + private int turn; + + public GameSession(World world, Player player, String slotName) { + this.world = world; + this.player = player; + this.slotName = slotName; + } + + public void setTurn(int turn) { + this.turn = turn; + } + + public void incrementTurn() { + this.turn++; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java new file mode 100644 index 0000000..9c729f0 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java @@ -0,0 +1,52 @@ +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(); + } +} From c1826c7df810a787843f65257111a4d6d2649230 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:19:54 +0200 Subject: [PATCH 04/22] test(game): strengthen legacy-constructor delegation assertions Co-Authored-By: Claude Sonnet 4.6 --- .../test/java/thb/jeanluc/adventure/game/GameSessionTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java index 9c729f0..d493cab 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameSessionTest.java @@ -48,5 +48,7 @@ class GameSessionTest { GameContext ctx = new GameContext(w, new Player(k, 0), new TestIO()); assertThat(ctx.getSession()).isNotNull(); assertThat(ctx.getSession().getTurn()).isZero(); + assertThat(ctx.getWorld()).isSameAs(w); + assertThat(ctx.getPlayer().getCurrentRoom()).isSameAs(k); } } From beba1c7e72521d2c48237c2e3be12dcfa03cf655 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:21:56 +0200 Subject: [PATCH 05/22] feat: restore hooks for flags, quest progress, switch state --- .../thb/jeanluc/adventure/game/GameState.java | 7 ++++ .../thb/jeanluc/adventure/game/QuestLog.java | 9 ++++ .../adventure/model/item/SwitchableItem.java | 10 +++++ .../adventure/game/RestoreHooksTest.java | 42 +++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java index 135f03f..b56d006 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java @@ -1,5 +1,6 @@ package thb.jeanluc.adventure.game; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.Set; @@ -24,4 +25,10 @@ public class GameState { public Set all() { return Collections.unmodifiableSet(flags); } + + /** Replaces all flags with the given collection (used by load). */ + public void restore(Collection newFlags) { + flags.clear(); + flags.addAll(newFlags); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java index d0d9bb0..8ba492d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java @@ -1,5 +1,6 @@ package thb.jeanluc.adventure.game; +import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -46,4 +47,12 @@ public class QuestLog { public Set completed() { return Collections.unmodifiableSet(completed); } + + /** Replaces all progress with the given stage map and completed set (load). */ + public void restore(Map stages, Collection completedIds) { + stageIndex.clear(); + stageIndex.putAll(stages); + completed.clear(); + completed.addAll(completedIds); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java index 8f8e1a3..f2b19d5 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java @@ -53,4 +53,14 @@ public class SwitchableItem extends Item { public boolean isOn() { return state; } + + /** + * Directly sets the on/off state without running effects or printing. + * Used only when restoring a saved game. + * + * @param state the state to restore + */ + public void setState(boolean state) { + this.state = state; + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java new file mode 100644 index 0000000..613a12c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/RestoreHooksTest.java @@ -0,0 +1,42 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestoreHooksTest { + + @Test + void gameStateRestoreReplacesFlags() { + GameState s = new GameState(); + s.set("old"); + s.restore(List.of("a", "b")); + assertThat(s.isSet("old")).isFalse(); + assertThat(s.all()).containsExactlyInAnyOrder("a", "b"); + } + + @Test + void questLogRestoreReplacesProgress() { + QuestLog q = new QuestLog(); + q.start("stale"); + q.restore(Map.of("active", 2), Set.of("done")); + assertThat(q.isActive("stale")).isFalse(); + assertThat(q.stageIndex("active")).isEqualTo(2); + assertThat(q.isCompleted("done")).isTrue(); + } + + @Test + void switchableSetStateDoesNotRunEffects() { + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("on").offText("off").state(false).effects(List.of()) + .build(); + lamp.setState(true); + assertThat(lamp.isOn()).isTrue(); + } +} From b73d0aaa82a13369041d87ed7a4fb247aea9f761 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:26:43 +0200 Subject: [PATCH 06/22] feat(io): GameIO.choose numbered-menu primitive (console default) --- .../java/thb/jeanluc/adventure/io/GameIO.java | 36 +++++++++++++++++++ .../adventure/io/ChooseDefaultTest.java | 32 +++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index 98584b0..9ca91b1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -9,6 +9,8 @@ import thb.jeanluc.adventure.io.text.Renderings; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.io.text.StyledText; +import java.util.List; + /** * Bidirectional channel between the engine and the player. Commands emit * semantic, role-styled output via {@link #print(StyledText)} (the primitive) @@ -56,4 +58,38 @@ public interface GameIO { default void showQuests(QuestView view) { print(QuestText.render(view)); } + + /** + * Presents a titled list of options and returns the 0-based index of the + * player's choice. Default (console) prints a numbered list and reads a + * number, re-prompting on invalid input. EOF / empty input returns the + * LAST option (menus place the safe/cancel option last). Frontends with + * native widgets (e.g. buttons) may override. + * + * @param title heading shown above the options + * @param options non-empty list of option labels + * @return 0-based index into {@code options} + */ + default int choose(String title, List options) { + StyledText.Builder b = StyledText.builder().heading(title).plain("\n"); + for (int i = 0; i < options.size(); i++) { + b.plain(" " + (i + 1) + ") " + options.get(i) + "\n"); + } + print(b.build()); + while (true) { + String line = readLine(); + if (line == null || line.isBlank()) { + return options.size() - 1; + } + try { + int n = Integer.parseInt(line.trim()); + if (n >= 1 && n <= options.size()) { + return n - 1; + } + } catch (NumberFormatException ignored) { + // fall through to re-prompt + } + write("Please enter a number between 1 and " + options.size() + "."); + } + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java new file mode 100644 index 0000000..d42ee6f --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ChooseDefaultTest.java @@ -0,0 +1,32 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ChooseDefaultTest { + + @Test + void returnsZeroBasedIndexOfValidChoice() { + TestIO io = new TestIO().enqueue("2"); + int i = io.choose("Menu", List.of("A", "B", "C")); + assertThat(i).isEqualTo(1); + } + + @Test + void rePromptsOnInvalidThenAccepts() { + TestIO io = new TestIO().enqueue("9").enqueue("x").enqueue("1"); + int i = io.choose("Menu", List.of("A", "B")); + assertThat(i).isZero(); + assertThat(io.allOutput()).contains("between 1 and 2"); + } + + @Test + void eofReturnsLastOption() { + TestIO io = new TestIO(); // empty queue → readLine() returns null + int i = io.choose("Menu", List.of("Play", "Quit")); + assertThat(i).isEqualTo(1); + } +} From 6d703c7caf069e8bc6d29e884586510729fc8958 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:29:55 +0200 Subject: [PATCH 07/22] feat(save): SaveData/SaveSlotInfo/SaveException model --- .../thb/jeanluc/adventure/save/SaveData.java | 29 +++++++++++++++++++ .../jeanluc/adventure/save/SaveException.java | 18 ++++++++++++ .../jeanluc/adventure/save/SaveSlotInfo.java | 6 ++++ .../jeanluc/adventure/save/SaveDataTest.java | 28 ++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java new file mode 100644 index 0000000..f6a96fe --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java @@ -0,0 +1,29 @@ +package thb.jeanluc.adventure.save; + +import java.util.List; +import java.util.Map; + +/** + * JSON-serialised snapshot of the mutable game state — a delta laid over the + * world freshly loaded from YAML. Header fields ({@code slotName}, room, + * {@code turn}, {@code savedAtEpochMillis}) double as the slot-list metadata. + */ +public record SaveData( + int schemaVersion, + String worldTitle, + String slotName, + long savedAtEpochMillis, + int turn, + String currentRoomId, + List visitedRoomIds, + int gold, + List inventoryItemIds, + List flags, + Map questStages, + List questCompleted, + Map> roomItemIds, + Map switchStates +) { + /** Current on-disk schema version. */ + public static final int CURRENT_VERSION = 1; +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java new file mode 100644 index 0000000..190d06b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveException.java @@ -0,0 +1,18 @@ +package thb.jeanluc.adventure.save; + +/** Thrown when a save cannot be written/read; carries a player-facing message. */ +public class SaveException extends RuntimeException { + + public SaveException(String message, Throwable cause) { + super(message, cause); + } + + public SaveException(String message) { + super(message); + } + + /** Player-facing message (the constructor message is already user-friendly). */ + public String getUserMessage() { + return getMessage(); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java new file mode 100644 index 0000000..dd7e186 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveSlotInfo.java @@ -0,0 +1,6 @@ +package thb.jeanluc.adventure.save; + +/** Lightweight metadata for one save slot, shown in the Load menu. */ +public record SaveSlotInfo(String slug, String slotName, String roomId, + int turn, long savedAtEpochMillis) { +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java new file mode 100644 index 0000000..95033d3 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveDataTest.java @@ -0,0 +1,28 @@ +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveDataTest { + + @Test + void roundTripsThroughJackson() throws Exception { + SaveData data = new SaveData( + 1, "Haunted Manor", "slot-a", 1700000000000L, 7, + "library", List.of("kitchen", "library"), 3, + List.of("key"), List.of("power_on"), + Map.of("restore_power", 1), List.of("intro"), + Map.of("cellar", List.of("shovel")), Map.of("lamp", true)); + ObjectMapper mapper = new ObjectMapper(); + String json = mapper.writeValueAsString(data); + SaveData back = mapper.readValue(json, SaveData.class); + assertThat(back).isEqualTo(data); + assertThat(back.currentRoomId()).isEqualTo("library"); + assertThat(back.switchStates()).containsEntry("lamp", true); + } +} From 80a51125084f7e2296045296539c0c47ea69c532 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:34:48 +0200 Subject: [PATCH 08/22] feat(save): SaveCodec capture/apply over a fresh world; tolerate null collections Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/save/SaveCodec.java | 126 ++++++++++++++++++ .../thb/jeanluc/adventure/save/SaveData.java | 15 +++ .../jeanluc/adventure/save/SaveCodecTest.java | 105 +++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java new file mode 100644 index 0000000..3cce28b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java @@ -0,0 +1,126 @@ +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.game.QuestLog; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Converts between a live {@link GameSession} and a {@link SaveData} snapshot. + * Pure (no disk): {@link #capture} reads a session; {@link #apply} overlays a + * snapshot onto a session built from a freshly loaded world. + */ +public final class SaveCodec { + + private SaveCodec() { + } + + /** Reads the mutable state of {@code session} into a {@link SaveData}. */ + public static SaveData capture(GameSession session) { + World w = session.getWorld(); + Player p = session.getPlayer(); + + Map> roomItems = new LinkedHashMap<>(); + for (Room room : w.getRooms().values()) { + if (!room.getItems().isEmpty()) { + roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet())); + } + } + Map switches = new LinkedHashMap<>(); + for (Item item : w.getItems().values()) { + if (item instanceof SwitchableItem sw) { + switches.put(sw.getId(), sw.isOn()); + } + } + Map stages = new LinkedHashMap<>(); + QuestLog log = session.getQuestLog(); + for (String id : log.active()) { + stages.put(id, log.stageIndex(id)); + } + + return new SaveData( + SaveData.CURRENT_VERSION, + w.getTitle(), + session.getSlotName(), + System.currentTimeMillis(), + session.getTurn(), + p.getCurrentRoom().getId(), + new ArrayList<>(p.getVisitedRoomIds()), + p.getGold(), + new ArrayList<>(p.getInventory().keySet()), + new ArrayList<>(session.getState().all()), + stages, + new ArrayList<>(log.completed()), + roomItems, + switches); + } + + /** + * Overlays {@code data} onto {@code session} (whose world was just loaded + * from YAML). Throws {@link SaveException} if the save references ids that + * no longer exist in the current world. + */ + public static void apply(SaveData data, GameSession session) { + World w = session.getWorld(); + Player p = session.getPlayer(); + + // 1. Clear all item placements, then redistribute from the save. + for (Room room : w.getRooms().values()) { + room.getItems().clear(); + } + p.getInventory().clear(); + for (Map.Entry> e : data.roomItemIds().entrySet()) { + Room room = requireRoom(w, e.getKey()); + for (String itemId : e.getValue()) { + room.addItem(requireItem(w, itemId)); + } + } + for (String itemId : data.inventoryItemIds()) { + p.addItem(requireItem(w, itemId)); + } + + // 2. Switch states. + for (Map.Entry e : data.switchStates().entrySet()) { + if (requireItem(w, e.getKey()) instanceof SwitchableItem sw) { + sw.setState(e.getValue()); + } + } + + // 3. Player position + visited + gold. + p.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; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java index f6a96fe..75fa852 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveData.java @@ -26,4 +26,19 @@ public record SaveData( ) { /** Current on-disk schema version. */ public static final int CURRENT_VERSION = 1; + + /** + * Compact canonical constructor: normalizes any null collection component + * to an empty immutable collection so a truncated/hand-edited save JSON + * degrades gracefully instead of NPE-ing in {@link SaveCodec#apply}. + */ + public SaveData { + if (visitedRoomIds == null) visitedRoomIds = List.of(); + if (inventoryItemIds == null) inventoryItemIds = List.of(); + if (flags == null) flags = List.of(); + if (questStages == null) questStages = Map.of(); + if (questCompleted == null) questCompleted = List.of(); + if (roomItemIds == null) roomItemIds = Map.of(); + if (switchStates == null) switchStates = Map.of(); + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java new file mode 100644 index 0000000..ed1b1ca --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java @@ -0,0 +1,105 @@ +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveCodecTest { + + /** Builds a fresh 2-room world: shovel in cellar, lamp (off) in kitchen. */ + private GameSession freshWorld(String slot) { + Room kitchen = new Room("kitchen", "Kitchen", "k"); + Room cellar = new Room("cellar", "Cellar", "c"); + kitchen.addExit(Direction.SOUTH, cellar); + Item shovel = PlainItem.builder().id("shovel").name("Shovel").description("d").light(false).build(); + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("on").offText("off").state(false).effects(List.of()).build(); + cellar.addItem(shovel); + kitchen.addItem(lamp); + Map items = new LinkedHashMap<>(); + items.put("shovel", shovel); + items.put("lamp", lamp); + Map rooms = new LinkedHashMap<>(); + rooms.put("kitchen", kitchen); + rooms.put("cellar", cellar); + World w = new World(rooms, items, Map.of(), "Haunted Manor", "welcome"); + return new GameSession(w, new Player(kitchen, 0), slot); + } + + @Test + void captureThenApplyToFreshWorldReproducesState() { + GameSession src = freshWorld("slot-a"); + // mutate: move to cellar, pick up shovel, light the lamp, set a flag, gold + src.getPlayer().setCurrentRoom(src.getWorld().getRooms().get("cellar")); + Item shovel = src.getWorld().getRooms().get("cellar").removeItem("shovel").orElseThrow(); + src.getPlayer().addItem(shovel); + ((SwitchableItem) src.getWorld().getItems().get("lamp")).setState(true); + src.getState().set("power_on"); + src.getPlayer().setGold(5); + src.setTurn(12); + + SaveData data = SaveCodec.capture(src); + + GameSession dst = freshWorld("slot-a"); + SaveCodec.apply(data, dst); + + assertThat(dst.getPlayer().getCurrentRoom().getId()).isEqualTo("cellar"); + assertThat(dst.getPlayer().hasItem("shovel")).isTrue(); + assertThat(dst.getWorld().getRooms().get("cellar").findItem("shovel")).isEmpty(); + assertThat(((SwitchableItem) dst.getWorld().getItems().get("lamp")).isOn()).isTrue(); + assertThat(dst.getState().isSet("power_on")).isTrue(); + assertThat(dst.getPlayer().getGold()).isEqualTo(5); + assertThat(dst.getTurn()).isEqualTo(12); + assertThat(dst.getPlayer().getVisitedRoomIds()).contains("kitchen", "cellar"); + } + + @Test + void applyRejectsUnknownRoom() { + GameSession src = freshWorld("slot-a"); + SaveData data = SaveCodec.capture(src); + SaveData broken = new SaveData( + data.schemaVersion(), data.worldTitle(), data.slotName(), + data.savedAtEpochMillis(), data.turn(), "ballroom", + data.visitedRoomIds(), data.gold(), data.inventoryItemIds(), + data.flags(), data.questStages(), data.questCompleted(), + data.roomItemIds(), data.switchStates()); + org.junit.jupiter.api.Assertions.assertThrows(SaveException.class, + () -> SaveCodec.apply(broken, freshWorld("slot-a"))); + } + + @Test + void applyToleratesMissingCollections() { + // Construct a SaveData with null collections (simulates a hand-edited / truncated save). + // The compact constructor should normalize nulls to empty collections. + SaveData data = new SaveData( + SaveData.CURRENT_VERSION, "Haunted Manor", "slot-a", + System.currentTimeMillis(), 0, + "kitchen", + null, // visitedRoomIds + 0, + null, // inventoryItemIds + null, // flags + null, // questStages + null, // questCompleted + null, // roomItemIds + null // switchStates + ); + + GameSession dst = freshWorld("slot-a"); + // Should not throw NPE; world items remain in their default positions or cleared + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> SaveCodec.apply(data, dst)); + } +} From 1172f7c3fd3207597f93f3ae488433e08df9f8e6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:42:15 +0200 Subject: [PATCH 09/22] fix(save): restore visited set before setting current room; strengthen null-tolerance test Reorder apply() so visitedRoomIds is restored before setCurrentRoom() is called, ensuring the current room is never accidentally stripped from the visited set by the subsequent clear(). Also adds a post-condition assertion to applyToleratesMissingCollections that proves null roomItemIds leaves room item tables empty after apply. Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/java/thb/jeanluc/adventure/save/SaveCodec.java | 2 +- .../src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java index 3cce28b..0f27340 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveCodec.java @@ -95,9 +95,9 @@ public final class SaveCodec { } // 3. Player position + visited + gold. - p.setCurrentRoom(requireRoom(w, data.currentRoomId())); p.getVisitedRoomIds().clear(); p.getVisitedRoomIds().addAll(data.visitedRoomIds()); + p.setCurrentRoom(requireRoom(w, data.currentRoomId())); p.setGold(data.gold()); // 4. Flags, quests, turn. diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java index ed1b1ca..569589e 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveCodecTest.java @@ -101,5 +101,7 @@ class SaveCodecTest { GameSession dst = freshWorld("slot-a"); // Should not throw NPE; world items remain in their default positions or cleared org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> SaveCodec.apply(data, dst)); + // Post-condition: applying a save with null/empty roomItemIds clears all room item tables. + assertThat(dst.getWorld().getRooms().get("kitchen").findItem("lamp")).isEmpty(); } } From 8eb433e02d690a7e67287a68326c10176a9091b0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:44:32 +0200 Subject: [PATCH 10/22] feat(save): SaveService disk persistence (atomic write, list, load) --- .../jeanluc/adventure/save/SaveService.java | 104 ++++++++++++++++++ .../adventure/save/SaveServiceTest.java | 63 +++++++++++ 2 files changed, 167 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java new file mode 100644 index 0000000..b4c5b9e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -0,0 +1,104 @@ +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.loader.WorldLoader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Stream; + +/** + * Reads and writes save slots as JSON files in a directory (default + * {@code ./saves}). One slot = one {@code .json}. Loading reloads the + * world from YAML and overlays the snapshot via {@link SaveCodec}. + */ +@Slf4j +public class SaveService { + + private final Path dir; + private final ObjectMapper mapper = new ObjectMapper(); + + /** Uses the default {@code ./saves} directory. */ + public SaveService() { + this(Path.of("saves")); + } + + public SaveService(Path dir) { + this.dir = dir; + } + + /** Slug used for the on-disk filename of a slot name. */ + public static String slug(String slotName) { + String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_"); + return s.isBlank() ? "save" : s; + } + + /** Writes the session to {@code .json} atomically. */ + public void save(GameSession session) { + SaveData data = SaveCodec.capture(session); + try { + Files.createDirectories(dir); + Path target = dir.resolve(slug(session.getSlotName()) + ".json"); + Path tmp = dir.resolve(slug(session.getSlotName()) + ".json.tmp"); + mapper.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), data); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException atomicFailed) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e); + } + } + + /** Lists all readable slots, newest first; skips unreadable files. */ + public List list() { + if (!Files.isDirectory(dir)) { + return List.of(); + } + List out = new ArrayList<>(); + try (Stream files = Files.list(dir)) { + for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) { + try { + SaveData d = mapper.readValue(f.toFile(), SaveData.class); + String slug = f.getFileName().toString().replaceFirst("\\.json$", ""); + out.add(new SaveSlotInfo(slug, d.slotName(), d.currentRoomId(), + d.turn(), d.savedAtEpochMillis())); + } catch (IOException badFile) { + log.warn("Skipping unreadable save file {}", f, badFile); + } + } + } catch (IOException e) { + log.warn("Could not list save directory {}", dir, e); + } + out.sort(Comparator.comparingLong(SaveSlotInfo::savedAtEpochMillis).reversed()); + return out; + } + + /** Loads a slot by slug: read JSON, reload world, overlay snapshot. */ + public GameSession load(String slug) { + Path file = dir.resolve(slug + ".json"); + SaveData data; + try { + data = mapper.readValue(file.toFile(), SaveData.class); + } catch (IOException e) { + throw new SaveException("Could not read save '" + slug + "': " + e.getMessage(), e); + } + if (data.schemaVersion() != SaveData.CURRENT_VERSION) { + throw new SaveException("Save '" + slug + "' uses an incompatible version (" + + data.schemaVersion() + "); expected " + SaveData.CURRENT_VERSION + "."); + } + WorldLoader.LoadResult fresh = new WorldLoader().load(); + GameSession session = new GameSession(fresh.world(), fresh.player(), data.slotName()); + SaveCodec.apply(data, session); + return session; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java new file mode 100644 index 0000000..9dcf54e --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -0,0 +1,63 @@ +package thb.jeanluc.adventure.save; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.loader.WorldLoader; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SaveServiceTest { + + private GameSession freshSession(String slot) { + WorldLoader.LoadResult r = new WorldLoader().load(); + return new GameSession(r.world(), r.player(), slot); + } + + @Test + void saveThenLoadReproducesState(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("my-game"); + s.getState().set("power_on"); + s.getPlayer().setGold(4); + s.setTurn(9); + svc.save(s); + + GameSession loaded = svc.load("my-game"); + assertThat(loaded.getState().isSet("power_on")).isTrue(); + assertThat(loaded.getPlayer().getGold()).isEqualTo(4); + assertThat(loaded.getTurn()).isEqualTo(9); + assertThat(loaded.getSlotName()).isEqualTo("my-game"); + } + + @Test + void listReturnsSlotMetadata(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("Alpha Run"); + s.setTurn(3); + svc.save(s); + + List slots = svc.list(); + assertThat(slots).hasSize(1); + assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run"); + assertThat(slots.getFirst().turn()).isEqualTo(3); + } + + @Test + void listSkipsCorruptFiles(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("broken.json"), "{ not json"); + assertThat(new SaveService(dir).list()).isEmpty(); + } + + @Test + void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("bad.json"), "{ not json"); + SaveService svc = new SaveService(dir); + assertThrows(SaveException.class, () -> svc.load("bad")); + } +} From 404221525c8ab231a07f6d8590d650d85387b02d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:50:10 +0200 Subject: [PATCH 11/22] test(save): cover version mismatch + slug; clean up tmp on move failure Add loadRejectsIncompatibleVersion test asserting SaveException on schema version 999, assert derived slug in listReturnsSlotMetadata, and harden save() to delete the orphaned .tmp file when the non-atomic fallback move also fails before re-throwing. Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/save/SaveService.java | 7 ++++++- .../thb/jeanluc/adventure/save/SaveServiceTest.java | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java index b4c5b9e..b5cf690 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -52,7 +52,12 @@ public class SaveService { try { Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (IOException atomicFailed) { - Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException fallbackFailed) { + Files.deleteIfExists(tmp); + throw fallbackFailed; + } } } catch (IOException e) { throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java index 9dcf54e..e321a87 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -45,6 +45,7 @@ class SaveServiceTest { List slots = svc.list(); assertThat(slots).hasSize(1); assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run"); + assertThat(slots.getFirst().slug()).isEqualTo("alpha_run"); assertThat(slots.getFirst().turn()).isEqualTo(3); } @@ -60,4 +61,16 @@ class SaveServiceTest { SaveService svc = new SaveService(dir); assertThrows(SaveException.class, () -> svc.load("bad")); } + + @Test + void loadRejectsIncompatibleVersion(@TempDir Path dir) throws Exception { + // a syntactically valid SaveData JSON but with a future schemaVersion + String json = "{\"schemaVersion\":999,\"worldTitle\":\"X\",\"slotName\":\"v\"," + + "\"savedAtEpochMillis\":0,\"turn\":0,\"currentRoomId\":\"k\"," + + "\"visitedRoomIds\":[],\"gold\":0,\"inventoryItemIds\":[],\"flags\":[]," + + "\"questStages\":{},\"questCompleted\":[],\"roomItemIds\":{},\"switchStates\":{}}"; + Files.writeString(dir.resolve("v.json"), json); + SaveService svc = new SaveService(dir); + assertThrows(SaveException.class, () -> svc.load("v")); + } } From dfc50f0d9fe2c6f47e6bf57996b6a264fd390b86 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:52:28 +0200 Subject: [PATCH 12/22] feat(settings): Settings + SettingsStore; runtime-togglable ConsoleIO --- .../thb/jeanluc/adventure/io/ConsoleIO.java | 16 ++++-- .../thb/jeanluc/adventure/save/Settings.java | 11 +++++ .../jeanluc/adventure/save/SettingsStore.java | 49 +++++++++++++++++++ .../adventure/io/ConsoleIOSettingsTest.java | 23 +++++++++ .../adventure/save/SettingsStoreTest.java | 27 ++++++++++ 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java index 8be6545..aaf7c26 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -32,8 +32,8 @@ public class ConsoleIO implements GameIO { private final BufferedReader in; private final PrintStream out; - private final boolean useColor; - private final GlyphMode glyphs; + private boolean useColor; + private GlyphMode glyphs; public ConsoleIO() { this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), @@ -49,13 +49,23 @@ public class ConsoleIO implements GameIO { this.in = in; this.out = out; this.glyphs = glyphs; - this.useColor = switch (colorMode) { + setColorMode(colorMode); + } + + /** Re-resolves colour emission from the given mode (AUTO checks the console). */ + public void setColorMode(ColorMode mode) { + this.useColor = switch (mode) { case ON -> true; case OFF -> false; case AUTO -> System.console() != null; }; } + /** Switches the frame/glyph fidelity tier live. */ + public void setGlyphMode(GlyphMode mode) { + this.glyphs = mode; + } + @Override public String readLine() { out.print("> "); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java new file mode 100644 index 0000000..cebac84 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/Settings.java @@ -0,0 +1,11 @@ +package thb.jeanluc.adventure.save; + +import thb.jeanluc.adventure.io.ConsoleIO; + +/** Persisted user preferences (minimal: colour + glyph fidelity). */ +public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) { + + public static Settings defaults() { + return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java new file mode 100644 index 0000000..c912041 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SettingsStore.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.save; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Loads/saves {@link Settings} as JSON (default {@code saves/settings.json}). */ +@Slf4j +public class SettingsStore { + + private final Path file; + private final ObjectMapper mapper = new ObjectMapper(); + + public SettingsStore() { + this(Path.of("saves", "settings.json")); + } + + public SettingsStore(Path file) { + this.file = file; + } + + /** Returns persisted settings, or {@link Settings#defaults()} on any problem. */ + public Settings load() { + if (!Files.isRegularFile(file)) { + return Settings.defaults(); + } + try { + return mapper.readValue(file.toFile(), Settings.class); + } catch (IOException e) { + log.warn("Could not read settings {}, using defaults", file, e); + return Settings.defaults(); + } + } + + /** Persists settings; logs and swallows failures (non-fatal). */ + public void save(Settings settings) { + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), settings); + } catch (IOException e) { + log.warn("Could not write settings {}", file, e); + } + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java new file mode 100644 index 0000000..927ddbb --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIOSettingsTest.java @@ -0,0 +1,23 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.StringReader; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsoleIOSettingsTest { + + @Test + void colorSetterTogglesAnsiEmission() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), + new PrintStream(out), ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + io.setColorMode(ConsoleIO.ColorMode.ON); + io.print(thb.jeanluc.adventure.io.text.StyledText.builder().heading("Hi").build()); + assertThat(out.toString()).contains("["); // ANSI escape present + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java new file mode 100644 index 0000000..8b959d0 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -0,0 +1,27 @@ +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()); + } +} From 735a92ca6e9937ff547649f0a9fc9eaf6b312163 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:56:14 +0200 Subject: [PATCH 13/22] test(settings): cover corrupt settings file falls back to defaults Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/save/SettingsStoreTest.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java index 8b959d0..9cca9fd 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import thb.jeanluc.adventure.io.ConsoleIO; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -24,4 +26,12 @@ class SettingsStoreTest { Settings loaded = new SettingsStore(dir.resolve("nope.json")).load(); assertThat(loaded).isEqualTo(Settings.defaults()); } + + @Test + void corruptFileReturnsDefaults(@TempDir Path dir) throws IOException { + Path file = dir.resolve("settings.json"); + Files.writeString(file, "{ not json"); + Settings loaded = new SettingsStore(file).load(); + assertThat(loaded).isEqualTo(Settings.defaults()); + } } From 6921af053a23dbe3e35a32c2473d7736fd8ee03e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:58:41 +0200 Subject: [PATCH 14/22] feat(menu): MainMenu, SettingsMenu, MenuAction (frontend-agnostic) --- .../thb/jeanluc/adventure/menu/MainMenu.java | 49 ++++++++++++++ .../jeanluc/adventure/menu/MenuAction.java | 6 ++ .../jeanluc/adventure/menu/SettingsMenu.java | 67 +++++++++++++++++++ .../thb/jeanluc/adventure/menu/MenuTest.java | 49 ++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java new file mode 100644 index 0000000..70f9821 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MainMenu.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; + +import java.util.ArrayList; +import java.util.List; + +/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */ +public final class MainMenu { + + private MainMenu() { + } + + /** Shows the main menu and returns the chosen action. */ + public static MenuAction show(GameIO io) { + int i = io.choose("HAUNTED MANOR", + List.of("New Game", "Load Game", "Settings", "Quit")); + return MenuAction.values()[i]; + } + + /** Prompts for a new save name; blank input yields {@code defaultName}. */ + public static String promptName(GameIO io, String defaultName) { + io.write("Name your save (Enter for \"" + defaultName + "\"):"); + String line = io.readLine(); + return (line == null || line.isBlank()) ? defaultName : line.trim(); + } + + /** + * Lets the player pick a save slot to load. Returns the chosen + * {@link SaveSlotInfo}, or {@code null} if there are none or the player + * picks "Back". + */ + public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) { + List slots = saves.list(); + if (slots.isEmpty()) { + io.write("No saved games."); + return null; + } + List labels = new ArrayList<>(); + for (SaveSlotInfo s : slots) { + labels.add(s.slotName() + " (" + s.roomId() + ", turn " + s.turn() + ")"); + } + labels.add("Back"); + int i = io.choose("LOAD GAME", labels); + return i < slots.size() ? slots.get(i) : null; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java new file mode 100644 index 0000000..9af99cf --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/MenuAction.java @@ -0,0 +1,6 @@ +package thb.jeanluc.adventure.menu; + +/** Top-level main-menu choices, in display order. */ +public enum MenuAction { + NEW_GAME, LOAD, SETTINGS, QUIT +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java new file mode 100644 index 0000000..3661505 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java @@ -0,0 +1,67 @@ +package thb.jeanluc.adventure.menu; + +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.save.Settings; +import thb.jeanluc.adventure.save.SettingsStore; + +import java.util.List; + +/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */ +public final class SettingsMenu { + + private SettingsMenu() { + } + + /** + * Loops the settings screen until the player picks "Back", persisting and + * applying each change. Returns the final settings. + * + * @param io active IO + * @param current starting settings + * @param store where to persist + * @param consoleIo the console IO to apply changes to live, or null if not console + */ + public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) { + Settings s = current; + while (true) { + int i = io.choose("SETTINGS", List.of( + "Colour: " + s.colorMode(), + "Glyphs: " + s.glyphMode(), + "Back")); + if (i == 0) { + s = new Settings(nextColor(s.colorMode()), s.glyphMode()); + } else if (i == 1) { + s = new Settings(s.colorMode(), nextGlyph(s.glyphMode())); + } else { + return s; + } + store.save(s); + apply(s, consoleIo); + } + } + + /** Applies colour/glyph settings to the console IO (no-op if null). */ + public static void apply(Settings s, ConsoleIO consoleIo) { + if (consoleIo != null) { + consoleIo.setColorMode(s.colorMode()); + consoleIo.setGlyphMode(s.glyphMode()); + } + } + + private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) { + return switch (m) { + case AUTO -> ConsoleIO.ColorMode.ON; + case ON -> ConsoleIO.ColorMode.OFF; + case OFF -> ConsoleIO.ColorMode.AUTO; + }; + } + + private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) { + return switch (m) { + case UNICODE -> ConsoleIO.GlyphMode.ASCII; + case ASCII -> ConsoleIO.GlyphMode.GLYPH; + case GLYPH -> ConsoleIO.GlyphMode.UNICODE; + }; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java new file mode 100644 index 0000000..40419cf --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java @@ -0,0 +1,49 @@ +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); + } +} From e564253f23910e8982dcc406592afb4f8c504544 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:03:18 +0200 Subject: [PATCH 15/22] test(menu): cover pickSlot selection and Back sentinel Co-Authored-By: Claude Sonnet 4.6 --- .../thb/jeanluc/adventure/menu/MenuTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java index 40419cf..3088e3b 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/menu/MenuTest.java @@ -2,8 +2,11 @@ package thb.jeanluc.adventure.menu; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.game.GameSession; import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SaveSlotInfo; import thb.jeanluc.adventure.save.Settings; import thb.jeanluc.adventure.save.SettingsStore; import thb.jeanluc.adventure.io.ConsoleIO; @@ -36,6 +39,28 @@ class MenuTest { assertThat(io.allOutput()).contains("No saved games"); } + private void saveSlot(Path dir, String name) { + var r = new WorldLoader().load(); + new SaveService(dir).save(new GameSession(r.world(), r.player(), name)); + } + + @Test + void pickSlotReturnsChosenSlot(@TempDir Path dir) { + saveSlot(dir, "Alpha"); + TestIO io = new TestIO().enqueue("1"); + SaveSlotInfo result = MainMenu.pickSlot(io, new SaveService(dir)); + assertThat(result).isNotNull(); + assertThat(result.slotName()).isEqualTo("Alpha"); + } + + @Test + void pickSlotReturnsNullOnBack(@TempDir Path dir) { + saveSlot(dir, "Alpha"); + // one slot → options are [1: Alpha, 2: Back] + TestIO io = new TestIO().enqueue("2"); + assertThat(MainMenu.pickSlot(io, new SaveService(dir))).isNull(); + } + @Test void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) { SettingsStore store = new SettingsStore(dir.resolve("settings.json")); From 4b2357cd5bcfa7a3a8d13cd144b5be5ba7b57a1d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:05:27 +0200 Subject: [PATCH 16/22] feat(command): save + menu commands (QuitCommand removal deferred to Task 11) --- .../adventure/command/impl/MenuCommand.java | 44 +++++++++++++++++ .../adventure/command/impl/SaveCommand.java | 31 ++++++++++++ .../command/SaveMenuCommandTest.java | 49 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java new file mode 100644 index 0000000..ee6145e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MenuCommand.java @@ -0,0 +1,44 @@ +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; + +import java.util.List; + +/** + * Saves the active slot and returns to the main menu by stopping the loop. + * Usage: {@code menu} (aliases {@code quit}, {@code exit}). + */ +@RequiredArgsConstructor +public class MenuCommand implements Command { + + private final SaveService saves; + private Game game; + + /** Binds the running game (two-phase wiring, as the registry is built first). */ + public void bind(Game game) { + this.game = game; + } + + @Override + public void execute(GameContext ctx, List args) { + try { + saves.save(ctx.getSession()); + ctx.getIo().write("Game saved. Returning to the main menu."); + } catch (SaveException e) { + ctx.getIo().write("Could not save: " + e.getUserMessage()); + } + if (game != null) { + game.stop(); + } + } + + @Override + public String help() { + return "menu - save and return to the main menu (aliases: quit, exit)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java new file mode 100644 index 0000000..54ce7fd --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/SaveCommand.java @@ -0,0 +1,31 @@ +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.save.SaveException; +import thb.jeanluc.adventure.save.SaveService; + +import java.util.List; + +/** Manually saves the active slot. Usage: {@code save}. */ +@RequiredArgsConstructor +public class SaveCommand implements Command { + + private final SaveService saves; + + @Override + public void execute(GameContext ctx, List args) { + try { + saves.save(ctx.getSession()); + ctx.getIo().write("Game saved."); + } catch (SaveException e) { + ctx.getIo().write(e.getUserMessage()); + } + } + + @Override + public String help() { + return "save - save your game to the current slot"; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java new file mode 100644 index 0000000..dbcd07b --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/SaveMenuCommandTest.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.command; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.command.impl.MenuCommand; +import thb.jeanluc.adventure.command.impl.SaveCommand; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.loader.WorldLoader; +import thb.jeanluc.adventure.save.SaveService; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SaveMenuCommandTest { + + private GameContext ctx(String slot) { + WorldLoader.LoadResult r = new WorldLoader().load(); + return new GameContext(new GameSession(r.world(), r.player(), slot), new TestIO()); + } + + @Test + void saveCommandWritesSlotAndReportsSuccess(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameContext ctx = ctx("game-a"); + new SaveCommand(svc).execute(ctx, List.of()); + assertThat(svc.list()).extracting(s -> s.slotName()).containsExactly("game-a"); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Game saved"); + } + + @Test + void menuCommandSavesThenStopsLoop(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + GameContext ctx = ctx("game-b"); + Game game = new Game(ctx, new CommandRegistry(), new CommandParser()); + MenuCommand menu = new MenuCommand(svc); + menu.bind(game); + menu.execute(ctx, List.of()); + assertThat(svc.list()).hasSize(1); + // loop is stopped: run() must return without consuming queued input + ((TestIO) ctx.getIo()).enqueue("look"); + game.run(); + assertThat(ctx.getIo().readLine()).isEqualTo("look"); // sentinel still unread + } +} From 9632d27b2526092871dc45773dbbb5b4b9c5c998 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:12:18 +0200 Subject: [PATCH 17/22] feat(game): autosave on quest completion --- .../jeanluc/adventure/game/QuestEngine.java | 1 + .../adventure/game/QuestAutosaveTest.java | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java index 7054116..46fc4cc 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java @@ -62,6 +62,7 @@ public final class QuestEngine { ctx.getQuestLog().complete(q.id()); ctx.getIo().print(StyledText.builder() .heading("★ Quest complete: ").plain(q.title()).build()); + ctx.save(); } public static QuestView viewOf(GameContext ctx) { diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java new file mode 100644 index 0000000..d3a94e2 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestAutosaveTest.java @@ -0,0 +1,37 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Quest; +import thb.jeanluc.adventure.model.QuestStage; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class QuestAutosaveTest { + + @Test + void questCompletionTriggersSaveCallback() { + Quest q = new Quest("q", "Quest", true, List.of( + new QuestStage("Do it", List.of(new Condition(Condition.Type.FLAG, "a")), List.of())), + List.of()); + Room k = new Room("k", "K", "d"); + World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w", Map.of("q", q)); + GameContext ctx = new GameContext(new GameSession(w, new Player(k, 0), "slot"), new TestIO()); + AtomicInteger saves = new AtomicInteger(); + ctx.setSaveCallback(saves::incrementAndGet); + + QuestEngine.tick(ctx); // starts quest; not complete yet + assertThat(saves.get()).isZero(); + ctx.getState().set("a"); + QuestEngine.tick(ctx); // completes quest → autosave + assertThat(saves.get()).isEqualTo(1); + } +} From 1f15bfe330c0cef48451c9ca3e0ad995e8a0474d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:17:46 +0200 Subject: [PATCH 18/22] feat(app): main-menu shell loop wiring; gitignore saves/ Replace App.run with a main-menu shell (New Game / Load / Settings / Quit) above the game loop, wire SaveCommand + MenuCommand into the registry with an autosave callback, and remove the now-unused QuitCommand. Migrate GameTest from QuitCommand to MenuCommand. Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/.gitignore | 3 + .../main/java/thb/jeanluc/adventure/App.java | 96 ++++++++++++++----- .../adventure/command/impl/QuitCommand.java | 41 -------- .../thb/jeanluc/adventure/game/GameTest.java | 14 ++- 4 files changed, 84 insertions(+), 70 deletions(-) delete mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore index b55fdef..90926aa 100644 --- a/Semesterprojekt/.gitignore +++ b/Semesterprojekt/.gitignore @@ -40,3 +40,6 @@ build/ ### Brainstorming visual companion ### .superpowers/ + +# Local save games and settings +saves/ diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index c609650..e20b232 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -11,23 +11,36 @@ import thb.jeanluc.adventure.command.impl.HelpCommand; import thb.jeanluc.adventure.command.impl.InventoryCommand; import thb.jeanluc.adventure.command.impl.LookCommand; import thb.jeanluc.adventure.command.impl.MapCommand; +import thb.jeanluc.adventure.command.impl.MenuCommand; import thb.jeanluc.adventure.command.impl.QuestsCommand; -import thb.jeanluc.adventure.command.impl.QuitCommand; import thb.jeanluc.adventure.command.impl.ReadCommand; +import thb.jeanluc.adventure.command.impl.SaveCommand; import thb.jeanluc.adventure.command.impl.TakeCommand; import thb.jeanluc.adventure.command.impl.TalkCommand; import thb.jeanluc.adventure.command.impl.UseCommand; import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; import thb.jeanluc.adventure.loader.WorldLoader; +import thb.jeanluc.adventure.menu.MainMenu; +import thb.jeanluc.adventure.menu.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; /** - * Entry point for the console version of the game. Loads the world from - * YAML, wires up the command registry, and hands control to the - * {@link Game} loop. + * Console entry point. Loads settings, then runs the main-menu shell loop: + * New Game / Load / Settings / Quit. Each game runs the shared {@link Game} + * loop and returns here when the player saves-and-exits, reaches an ending, + * or input ends. Shared by console and GUI via {@link #run(GameIO)}. */ @Slf4j public final class App { @@ -35,31 +48,63 @@ public final class App { private App() { } - /** - * Standard JVM entry point. - * - * @param args ignored - */ public static void main(String[] args) { GameIO io = new ConsoleIO(); try { run(io); } catch (RuntimeException e) { - log.error("Fatal error during game startup", e); + log.error("Fatal error", e); io.write("Fatal error: " + e.getMessage()); System.exit(1); } } - /** - * Boots the game on the given IO channel. Reusable by both - * {@link App} (console) and {@code AppGui} (Swing worker thread). - * - * @param io the IO channel to use - */ + /** Runs the menu shell on the given IO until the player quits. */ public static void run(GameIO io) { + SaveService saves = new SaveService(); + SettingsStore settingsStore = new SettingsStore(); + Settings settings = settingsStore.load(); + ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null; + SettingsMenu.apply(settings, consoleIo); + + boolean running = true; + while (running) { + switch (MainMenu.show(io)) { + case NEW_GAME -> play(io, saves, newSession(io, 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(); - GameContext ctx = new GameContext(loaded.world(), loaded.player(), io); + String name = MainMenu.promptName(io, "Manor"); + return new GameSession(loaded.world(), loaded.player(), name); + } + + /** Builds the registry, runs one game to completion, autosaving to its slot. */ + private static void play(GameIO io, SaveService saves, GameSession session) { + GameContext ctx = new GameContext(session, io); + ctx.setSaveCallback(() -> { + try { + saves.save(session); + } catch (SaveException e) { + log.warn("Autosave failed", e); + } + }); CommandRegistry registry = new CommandRegistry(); registry.register(new GoCommand(), "go", "move", "walk"); @@ -74,17 +119,18 @@ public final class App { registry.register(new QuestsCommand(), "quests", "log", "journal"); registry.register(new TalkCommand(), "talk", "speak"); registry.register(new GiveCommand(), "give"); - registry.register(new HelpCommand(registry), "help", "?"); - - QuitCommand quit = new QuitCommand(); - registry.register(quit, "quit", "exit"); + registry.register(new SaveCommand(saves), "save"); Game game = new Game(ctx, registry, new CommandParser()); - quit.bind(game); - io.print(Banner.welcome(loaded.world().getTitle())); - io.write(loaded.world().getWelcomeMessage()); - new LookCommand().execute(ctx, java.util.List.of()); + MenuCommand menu = new MenuCommand(saves); + menu.bind(game); + registry.register(menu, "menu", "quit", "exit"); + registry.register(new HelpCommand(registry), "help", "?"); + + io.print(Banner.welcome(session.getWorld().getTitle())); + io.write(session.getWorld().getWelcomeMessage()); + new LookCommand().execute(ctx, List.of()); game.run(); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java deleted file mode 100644 index 7282498..0000000 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java +++ /dev/null @@ -1,41 +0,0 @@ -package thb.jeanluc.adventure.command.impl; - -import thb.jeanluc.adventure.command.Command; -import thb.jeanluc.adventure.game.Game; -import thb.jeanluc.adventure.game.GameContext; - -import java.util.List; - -/** - * Ends the game loop. Holds a reference to the {@link Game} so it can - * flip the loop flag. Usage: {@code quit}. - */ -public class QuitCommand implements Command { - - /** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */ - private Game game; - - /** - * Binds the game instance after construction. Two-phase wiring is - * necessary because the registry is typically built before the game - * starts running. - * - * @param game the running game; must not be null - */ - public void bind(Game game) { - this.game = game; - } - - @Override - public void execute(GameContext ctx, List args) { - ctx.getIo().write("Goodbye."); - if (game != null) { - game.stop(); - } - } - - @Override - public String help() { - return "quit - exit the game (alias: exit)"; - } -} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java index 4b85ced..8b8b91d 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java @@ -1,17 +1,20 @@ package thb.jeanluc.adventure.game; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import thb.jeanluc.adventure.command.CommandParser; import thb.jeanluc.adventure.command.CommandRegistry; import thb.jeanluc.adventure.command.impl.GoCommand; import thb.jeanluc.adventure.command.impl.LookCommand; -import thb.jeanluc.adventure.command.impl.QuitCommand; +import thb.jeanluc.adventure.command.impl.MenuCommand; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.save.SaveService; +import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.Map; @@ -19,6 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat; class GameTest { + @TempDir + Path savesDir; + @Test void run_dispatchesCommands_andQuitsCleanly() { Room kitchen = new Room("kitchen", "Kitchen", "kd"); @@ -36,7 +42,7 @@ class GameTest { CommandRegistry registry = new CommandRegistry(); registry.register(new GoCommand(), "go"); registry.register(new LookCommand(), "look"); - QuitCommand quit = new QuitCommand(); + MenuCommand quit = new MenuCommand(new SaveService(savesDir)); registry.register(quit, "quit"); Game game = new Game(ctx, registry, new CommandParser()); @@ -44,7 +50,7 @@ class GameTest { game.run(); assertThat(player.getCurrentRoom()).isEqualTo(hallway); - assertThat(io.allOutput()).contains("Goodbye"); + assertThat(io.allOutput()).contains("Returning to the main menu"); } @Test @@ -55,7 +61,7 @@ class GameTest { GameContext ctx = new GameContext(new World(Map.of("r", r), Map.of(), Map.of(), "t", "w"), player, io); CommandRegistry registry = new CommandRegistry(); - QuitCommand quit = new QuitCommand(); + MenuCommand quit = new MenuCommand(new SaveService(savesDir)); registry.register(quit, "quit"); Game game = new Game(ctx, registry, new CommandParser()); quit.bind(game); From 163e2cc11b40a092dad65542acc3b4536fc93775 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:25:01 +0200 Subject: [PATCH 19/22] refactor(app): drop dead newSession param, unused import, restore main javadoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `saves` parameter from `newSession(GameIO, SaveService)` → signature is now `newSession(GameIO)`, call site updated accordingly - Remove unused `import thb.jeanluc.adventure.menu.MenuAction` (switch uses unqualified constants; import was dead, compile confirmed clean) - Restore `/** Standard JVM entry point. @param args ignored */` Javadoc on `main` and restore full log message "Fatal error during game startup" Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/java/thb/jeanluc/adventure/App.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index e20b232..8444308 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -26,7 +26,6 @@ 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; @@ -48,12 +47,13 @@ public final class App { private App() { } + /** Standard JVM entry point. @param args ignored */ public static void main(String[] args) { GameIO io = new ConsoleIO(); try { run(io); } catch (RuntimeException e) { - log.error("Fatal error", e); + log.error("Fatal error during game startup", e); io.write("Fatal error: " + e.getMessage()); System.exit(1); } @@ -70,7 +70,7 @@ public final class App { boolean running = true; while (running) { switch (MainMenu.show(io)) { - case NEW_GAME -> play(io, saves, newSession(io, saves)); + case NEW_GAME -> play(io, saves, newSession(io)); case LOAD -> { SaveSlotInfo slot = MainMenu.pickSlot(io, saves); if (slot != null) { @@ -89,7 +89,7 @@ public final class App { } /** Loads a fresh world and binds it to a newly named session. */ - private static GameSession newSession(GameIO io, SaveService saves) { + private static GameSession newSession(GameIO io) { WorldLoader.LoadResult loaded = new WorldLoader().load(); String name = MainMenu.promptName(io, "Manor"); return new GameSession(loaded.world(), loaded.player(), name); From 635ea8004c0e2c1a4585a762d7a734343347fd2a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:27:15 +0200 Subject: [PATCH 20/22] feat(io): SwingIO.choose renders the menu as buttons --- .../thb/jeanluc/adventure/io/SwingIO.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index fb4e4be..e40210f 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -10,7 +10,11 @@ import thb.jeanluc.adventure.io.text.StyledText; import javax.swing.AbstractAction; import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; import javax.swing.JComponent; +import javax.swing.JDialog; import javax.swing.JFrame; import javax.swing.JLabel; import javax.swing.JPanel; @@ -342,6 +346,51 @@ public class SwingIO implements GameIO { // The GUI map panel is always visible; nothing extra to do on the 'map' command. } + @Override + public int choose(String title, List options) { + // Same EDT↔worker handoff as readLine: the worker blocks on take(), + // the EDT (button click / window close) offers the chosen index. + LinkedBlockingQueue picked = new LinkedBlockingQueue<>(); + SwingUtilities.invokeLater(() -> { + JDialog dialog = new JDialog(frame, title, true); + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + panel.add(new JLabel(title)); + panel.add(Box.createVerticalStrut(8)); + for (int i = 0; i < options.size(); i++) { + int idx = i; + JButton b = new JButton(options.get(i)); + b.setAlignmentX(0f); + b.addActionListener(e -> { + dialog.dispose(); + picked.offer(idx); + }); + panel.add(b); + panel.add(Box.createVerticalStrut(4)); + } + // Closing the dialog counts as the last (safe/cancel) option. + dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE); + dialog.addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(java.awt.event.WindowEvent e) { + dialog.dispose(); + picked.offer(options.size() - 1); + } + }); + dialog.setContentPane(panel); + dialog.pack(); + dialog.setLocationRelativeTo(frame); + dialog.setVisible(true); + }); + try { + return picked.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return options.size() - 1; + } + } + private void addList(List spans, List xs, Style st) { for (int i = 0; i < xs.size(); i++) { if (i > 0) { From 8a8700994c2a6e606f3ae6bd8519247a0183942a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:30:51 +0200 Subject: [PATCH 21/22] docs: mark main menu + save/load implemented; note minimal settings scope Co-Authored-By: Claude Sonnet 4.6 --- Semesterprojekt/docs/enhancement-ideas.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 883c3b7..8a19c7e 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -144,6 +144,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ### 6. Hauptmenü & Settings +> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb +> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue +> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons). +> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live +> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles +> weiter zurückgestellt (Features existieren noch nicht). + - **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*. - **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl, Zeitstempel) anzeigen und hineinladen. @@ -154,6 +161,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ### 7. Speichern / Laden +> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als +> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/ +> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`, +> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern + +> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü. +> `saves/` ist gitignored. + - Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume, Quest-Fortschritt) – Format z.B. YAML/JSON. - Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü. @@ -175,6 +189,9 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ## Festgelegte Erweiterungs-Entscheidungen +> **Pathfinding** (BFS `go to `, Trie-Autocomplete) bleibt für eine +> spätere Runde im Scope. + | Entscheidung | Wert | |---|---| | Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) | From 9d3056d53e2bd0a3c815eca0cc219ba9cd89f601 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 15:39:08 +0200 Subject: [PATCH 22/22] fix: EOF ends game loop (ConsoleIO returns null on EOF); settings.json excluded from save slots Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/ConsoleIO.java | 7 ++++--- .../jeanluc/adventure/save/SaveService.java | 13 +++++++++++-- .../adventure/io/ConsoleIORenderTest.java | 19 +++++++++++++++++++ .../adventure/save/SaveServiceTest.java | 17 +++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java index aaf7c26..ec1afc6 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -71,10 +71,11 @@ public class ConsoleIO implements GameIO { out.print("> "); out.flush(); try { - String line = in.readLine(); - return line == null ? "" : line; + // null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts). + return in.readLine(); } catch (IOException e) { - return ""; + // Treat a stream failure as EOF so input ends cleanly rather than spinning. + return null; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java index b5cf690..f812d4f 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -23,6 +23,8 @@ import java.util.stream.Stream; @Slf4j public class SaveService { + private static final String SETTINGS_FILENAME = "settings.json"; + private final Path dir; private final ObjectMapper mapper = new ObjectMapper(); @@ -38,7 +40,11 @@ public class SaveService { /** 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; + if (s.isBlank()) { + return "save"; + } + // Never let a user-named save collide with the settings file. + return s.equals("settings") ? "settings_" : s; } /** Writes the session to {@code .json} atomically. */ @@ -71,7 +77,10 @@ public class SaveService { } List out = new ArrayList<>(); try (Stream files = Files.list(dir)) { - for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) { + for (Path f : files + .filter(p -> p.toString().endsWith(".json")) + .filter(p -> !p.getFileName().toString().equals(SETTINGS_FILENAME)) + .toList()) { try { SaveData d = mapper.readValue(f.toFile(), SaveData.class); String slug = f.getFileName().toString().replaceFirst("\\.json$", ""); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java index 21735b1..19db2e3 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java @@ -50,6 +50,25 @@ class ConsoleIORenderTest { assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off"); } + @Test + void readLine_returnsNullOnEof() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), ps, + ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + assertThat(io.readLine()).isNull(); + } + + @Test + void readLine_returnsEmptyStringForBlankLineThenNullAtEof() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8); + ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("\n")), ps, + ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + assertThat(io.readLine()).isEqualTo(""); // blank interactive line, re-prompt + assertThat(io.readLine()).isNull(); // stream now exhausted → EOF + } + @Test void asciiGlyphMode_usesPlusDashFrame() { ByteArrayOutputStream sink = new ByteArrayOutputStream(); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java index e321a87..f5aba96 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -55,6 +55,23 @@ class SaveServiceTest { assertThat(new SaveService(dir).list()).isEmpty(); } + @Test + void listExcludesSettingsFile(@TempDir Path dir) throws Exception { + SaveService svc = new SaveService(dir); + GameSession s = freshSession("Real Save"); + svc.save(s); + Files.writeString(dir.resolve("settings.json"), "{\"color\":\"on\"}"); + + List slots = svc.list(); + assertThat(slots).hasSize(1); + assertThat(slots.getFirst().slotName()).isEqualTo("Real Save"); + } + + @Test + void slugForSettingsDoesNotCollideWithSettingsFile() { + assertThat(SaveService.slug("settings")).isNotEqualTo("settings"); + } + @Test void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception { Files.writeString(dir.resolve("bad.json"), "{ not json");