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>
This commit is contained in:
@@ -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<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.
|
||||||
Reference in New Issue
Block a user