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