From 2f96b7896b32bef3e1c35049a6e84113018670c3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 14:00:32 +0200 Subject: [PATCH] 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.