Files
Jander_Semester2/Semesterprojekt/docs/superpowers/specs/2026-06-01-main-menu-save-load-design.md
Jean-Luc Makiola 2f96b7896b 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) <noreply@anthropic.com>
2026-06-01 14:00:32 +02:00

282 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spec: Hauptmenü + Speichern/Laden (+ minimale Settings)
Stand: 2026-06-01. Erstes Teilprojekt der Erweiterungsrunde nach Abschluss von
Phasen 17 (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<String> 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 <raum>` (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<String> 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<String> visitedRoomIds, // geordnet
int gold,
List<String> inventoryItemIds, // geordnet
List<String> flags, // gesetzte World-Flags
Map<String,Integer> questStages, // aktive Quest → Stage-Index
List<String> questCompleted,
Map<String,List<String>> roomItemIds, // roomId → Item-Ids im Raum
Map<String,Boolean> 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/<slug>.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<SaveSlotInfo>`**: `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.