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

13 KiB
Raw Blame History

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.

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)

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:

/** 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-menuMenuCommand 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:

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 WorldLoaderSaveApplier 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.
public record SaveSlotInfo(String slug, String slotName, String roomId,
                           int turn, long savedAtEpochMillis) {}

10. Minimale Settings (game/io)

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 → saveload → 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.