Merge main menu + save/load into develop
Adds a main-menu shell above the game loop (New Game / Load / Settings / Quit) via a new GameIO.choose primitive (console numbered menu + Swing buttons), JSON save/load as a delta over the YAML world (single active slot; manual save, autosave on quest-completion + every 10 turns, and quit-to-menu), and minimal runtime-togglable settings (colour + glyph). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
Semesterprojekt/.gitignore
vendored
3
Semesterprojekt/.gitignore
vendored
@@ -40,3 +40,6 @@ build/
|
||||
|
||||
### Brainstorming visual companion ###
|
||||
.superpowers/
|
||||
|
||||
# Local save games and settings
|
||||
saves/
|
||||
|
||||
@@ -144,6 +144,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
||||
|
||||
### 6. Hauptmenü & Settings
|
||||
|
||||
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). Hauptmenü-Shell oberhalb
|
||||
> des Game-Loops (Neues Spiel / Laden / Einstellungen / Beenden) über neue
|
||||
> `GameIO.choose`-Primitive (Konsole: nummeriertes Menü; GUI: Buttons).
|
||||
> **Minimale Settings**: nur Farb-Modus + Glyphen-Modus (ASCII/Unicode), live
|
||||
> umschaltbar, in `saves/settings.json` persistiert. Musik-/Typewriter-Toggles
|
||||
> weiter zurückgestellt (Features existieren noch nicht).
|
||||
|
||||
- **Hauptmenü** vor der Spielschleife: *Neues Spiel*, *Spiel laden*, *Einstellungen*, *Beenden*.
|
||||
- **Spielstände-Liste**: gespeicherte Spiele mit Metadaten (Name, Raum, Zugzahl,
|
||||
Zeitstempel) anzeigen und hineinladen.
|
||||
@@ -154,6 +161,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
||||
|
||||
### 7. Speichern / Laden
|
||||
|
||||
> ✅ umgesetzt (Branch `feature/main-menu-save-load`). JSON-Spielstand als
|
||||
> Delta über die frisch aus YAML geladene Welt (`SaveData`/`SaveCodec`/
|
||||
> `SaveService`, atomare Writes). **Ein aktiver Slot**: manuelles `save`,
|
||||
> Autosave (Quest-Completion + alle 10 Züge) und `quit`/`menu` (speichern +
|
||||
> zurück ins Menü) schreiben denselben Slot; Laden nur über das Menü.
|
||||
> `saves/` ist gitignored.
|
||||
|
||||
- Spielstand serialisieren (Player-Zustand, Inventar, World-Flags, besuchte Räume,
|
||||
Quest-Fortschritt) – Format z.B. YAML/JSON.
|
||||
- Mehrere Speicherstände, benennbar; Anbindung an die Spielstände-Liste im Menü.
|
||||
@@ -175,6 +189,9 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
||||
|
||||
## Festgelegte Erweiterungs-Entscheidungen
|
||||
|
||||
> **Pathfinding** (BFS `go to <raum>`, Trie-Autocomplete) bleibt für eine
|
||||
> spätere Runde im Scope.
|
||||
|
||||
| Entscheidung | Wert |
|
||||
|---|---|
|
||||
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -11,23 +11,35 @@ import thb.jeanluc.adventure.command.impl.HelpCommand;
|
||||
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||
import thb.jeanluc.adventure.command.impl.MapCommand;
|
||||
import thb.jeanluc.adventure.command.impl.MenuCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuestsCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
||||
import thb.jeanluc.adventure.command.impl.SaveCommand;
|
||||
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||
import thb.jeanluc.adventure.command.impl.TalkCommand;
|
||||
import thb.jeanluc.adventure.command.impl.UseCommand;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.io.text.Banner;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
import thb.jeanluc.adventure.menu.MainMenu;
|
||||
import thb.jeanluc.adventure.menu.SettingsMenu;
|
||||
import thb.jeanluc.adventure.save.SaveException;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||
import thb.jeanluc.adventure.save.Settings;
|
||||
import thb.jeanluc.adventure.save.SettingsStore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Entry point for the console version of the game. Loads the world from
|
||||
* YAML, wires up the command registry, and hands control to the
|
||||
* {@link Game} loop.
|
||||
* Console entry point. Loads settings, then runs the main-menu shell loop:
|
||||
* New Game / Load / Settings / Quit. Each game runs the shared {@link Game}
|
||||
* loop and returns here when the player saves-and-exits, reaches an ending,
|
||||
* or input ends. Shared by console and GUI via {@link #run(GameIO)}.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class App {
|
||||
@@ -35,11 +47,7 @@ public final class App {
|
||||
private App() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JVM entry point.
|
||||
*
|
||||
* @param args ignored
|
||||
*/
|
||||
/** Standard JVM entry point. @param args ignored */
|
||||
public static void main(String[] args) {
|
||||
GameIO io = new ConsoleIO();
|
||||
try {
|
||||
@@ -51,15 +59,52 @@ public final class App {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots the game on the given IO channel. Reusable by both
|
||||
* {@link App} (console) and {@code AppGui} (Swing worker thread).
|
||||
*
|
||||
* @param io the IO channel to use
|
||||
*/
|
||||
/** Runs the menu shell on the given IO until the player quits. */
|
||||
public static void run(GameIO io) {
|
||||
SaveService saves = new SaveService();
|
||||
SettingsStore settingsStore = new SettingsStore();
|
||||
Settings settings = settingsStore.load();
|
||||
ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;
|
||||
SettingsMenu.apply(settings, consoleIo);
|
||||
|
||||
boolean running = true;
|
||||
while (running) {
|
||||
switch (MainMenu.show(io)) {
|
||||
case NEW_GAME -> play(io, saves, newSession(io));
|
||||
case LOAD -> {
|
||||
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
||||
if (slot != null) {
|
||||
try {
|
||||
play(io, saves, saves.load(slot.slug()));
|
||||
} catch (SaveException e) {
|
||||
io.write("Could not load: " + e.getUserMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo);
|
||||
case QUIT -> running = false;
|
||||
}
|
||||
}
|
||||
io.write("Farewell.");
|
||||
}
|
||||
|
||||
/** Loads a fresh world and binds it to a newly named session. */
|
||||
private static GameSession newSession(GameIO io) {
|
||||
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||
GameContext ctx = new GameContext(loaded.world(), loaded.player(), io);
|
||||
String name = MainMenu.promptName(io, "Manor");
|
||||
return new GameSession(loaded.world(), loaded.player(), name);
|
||||
}
|
||||
|
||||
/** Builds the registry, runs one game to completion, autosaving to its slot. */
|
||||
private static void play(GameIO io, SaveService saves, GameSession session) {
|
||||
GameContext ctx = new GameContext(session, io);
|
||||
ctx.setSaveCallback(() -> {
|
||||
try {
|
||||
saves.save(session);
|
||||
} catch (SaveException e) {
|
||||
log.warn("Autosave failed", e);
|
||||
}
|
||||
});
|
||||
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
registry.register(new GoCommand(), "go", "move", "walk");
|
||||
@@ -74,17 +119,18 @@ public final class App {
|
||||
registry.register(new QuestsCommand(), "quests", "log", "journal");
|
||||
registry.register(new TalkCommand(), "talk", "speak");
|
||||
registry.register(new GiveCommand(), "give");
|
||||
registry.register(new HelpCommand(registry), "help", "?");
|
||||
|
||||
QuitCommand quit = new QuitCommand();
|
||||
registry.register(quit, "quit", "exit");
|
||||
registry.register(new SaveCommand(saves), "save");
|
||||
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
quit.bind(game);
|
||||
|
||||
io.print(Banner.welcome(loaded.world().getTitle()));
|
||||
io.write(loaded.world().getWelcomeMessage());
|
||||
new LookCommand().execute(ctx, java.util.List.of());
|
||||
MenuCommand menu = new MenuCommand(saves);
|
||||
menu.bind(game);
|
||||
registry.register(menu, "menu", "quit", "exit");
|
||||
registry.register(new HelpCommand(registry), "help", "?");
|
||||
|
||||
io.print(Banner.welcome(session.getWorld().getTitle()));
|
||||
io.write(session.getWorld().getWelcomeMessage());
|
||||
new LookCommand().execute(ctx, List.of());
|
||||
|
||||
game.run();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.save.SaveException;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Saves the active slot and returns to the main menu by stopping the loop.
|
||||
* Usage: {@code menu} (aliases {@code quit}, {@code exit}).
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
public class MenuCommand implements Command {
|
||||
|
||||
private final SaveService saves;
|
||||
private Game game;
|
||||
|
||||
/** Binds the running game (two-phase wiring, as the registry is built first). */
|
||||
public void bind(Game game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
try {
|
||||
saves.save(ctx.getSession());
|
||||
ctx.getIo().write("Game saved. Returning to the main menu.");
|
||||
} catch (SaveException e) {
|
||||
ctx.getIo().write("Could not save: " + e.getUserMessage());
|
||||
}
|
||||
if (game != null) {
|
||||
game.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "menu - save and return to the main menu (aliases: quit, exit)";
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Ends the game loop. Holds a reference to the {@link Game} so it can
|
||||
* flip the loop flag. Usage: {@code quit}.
|
||||
*/
|
||||
public class QuitCommand implements Command {
|
||||
|
||||
/** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */
|
||||
private Game game;
|
||||
|
||||
/**
|
||||
* Binds the game instance after construction. Two-phase wiring is
|
||||
* necessary because the registry is typically built before the game
|
||||
* starts running.
|
||||
*
|
||||
* @param game the running game; must not be null
|
||||
*/
|
||||
public void bind(Game game) {
|
||||
this.game = game;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
ctx.getIo().write("Goodbye.");
|
||||
if (game != null) {
|
||||
game.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "quit - exit the game (alias: exit)";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package thb.jeanluc.adventure.command.impl;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.command.Command;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.save.SaveException;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Manually saves the active slot. Usage: {@code save}. */
|
||||
@RequiredArgsConstructor
|
||||
public class SaveCommand implements Command {
|
||||
|
||||
private final SaveService saves;
|
||||
|
||||
@Override
|
||||
public void execute(GameContext ctx, List<String> args) {
|
||||
try {
|
||||
saves.save(ctx.getSession());
|
||||
ctx.getIo().write("Game saved.");
|
||||
} catch (SaveException e) {
|
||||
ctx.getIo().write(e.getUserMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "save - save your game to the current slot";
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,6 @@ public class Game {
|
||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||
private boolean running = true;
|
||||
|
||||
/** Number of commands processed this session; shown in the HUD. */
|
||||
private int turn = 0;
|
||||
|
||||
/**
|
||||
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
||||
*/
|
||||
@@ -55,7 +52,10 @@ public class Game {
|
||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||
} else {
|
||||
cmd.get().execute(ctx, parsed.args());
|
||||
turn++;
|
||||
ctx.getSession().incrementTurn();
|
||||
if (ctx.getSession().getTurn() % 10 == 0) {
|
||||
ctx.save();
|
||||
}
|
||||
}
|
||||
publishHud();
|
||||
maybeEnd();
|
||||
@@ -66,7 +66,7 @@ public class Game {
|
||||
private void maybeEnd() {
|
||||
Ending e = EndingEngine.triggered(ctx);
|
||||
if (e != null) {
|
||||
ctx.getIo().print(EndingEngine.render(e, ctx, turn));
|
||||
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
|
||||
stop();
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class Game {
|
||||
ctx.getIo().setHud(new Hud(
|
||||
ctx.getPlayer().getCurrentRoom().getName(),
|
||||
ctx.getPlayer().getGold(),
|
||||
turn,
|
||||
ctx.getSession().getTurn(),
|
||||
Light.carryingLight(ctx)));
|
||||
MapView map = MapLayout.compute(
|
||||
ctx.getWorld(),
|
||||
|
||||
@@ -1,32 +1,63 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
/**
|
||||
* Bundle of everything a command needs to do its work: the world, the
|
||||
* player, and the IO channel. Passing this single object keeps command
|
||||
* signatures small and consistent.
|
||||
* Bundle of everything a command needs: the active {@link GameSession}
|
||||
* (world, player, flags, quests, turn) and the IO channel. Getters delegate
|
||||
* to the session so existing command code keeps working unchanged.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class GameContext {
|
||||
|
||||
/** Loaded world (rooms, items, npcs). */
|
||||
private final World world;
|
||||
|
||||
/** The player avatar. */
|
||||
private final Player player;
|
||||
|
||||
/** IO channel for reading input and writing output. */
|
||||
private final GameSession session;
|
||||
private final GameIO io;
|
||||
|
||||
/** Mutable world flags. Created fresh per context; not a constructor arg. */
|
||||
private final GameState state = new GameState();
|
||||
/** Silent autosave hook, wired by the app; no-op by default (tests). */
|
||||
private Runnable saveCallback = () -> { };
|
||||
|
||||
/** Runtime quest progress. Created fresh per context; not a constructor arg. */
|
||||
private final QuestLog questLog = new QuestLog();
|
||||
public GameContext(GameSession session, GameIO io) {
|
||||
this.session = session;
|
||||
this.io = io;
|
||||
}
|
||||
|
||||
/** Convenience for tests/legacy callers: wraps a fresh single-use session. */
|
||||
public GameContext(World world, Player player, GameIO io) {
|
||||
this(new GameSession(world, player, "session"), io);
|
||||
}
|
||||
|
||||
public GameSession getSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
public World getWorld() {
|
||||
return session.getWorld();
|
||||
}
|
||||
|
||||
public Player getPlayer() {
|
||||
return session.getPlayer();
|
||||
}
|
||||
|
||||
public GameState getState() {
|
||||
return session.getState();
|
||||
}
|
||||
|
||||
public QuestLog getQuestLog() {
|
||||
return session.getQuestLog();
|
||||
}
|
||||
|
||||
public GameIO getIo() {
|
||||
return io;
|
||||
}
|
||||
|
||||
/** Sets the silent autosave hook (called by interval/quest autosave). */
|
||||
public void setSaveCallback(Runnable saveCallback) {
|
||||
this.saveCallback = saveCallback;
|
||||
}
|
||||
|
||||
/** Runs the silent autosave hook. */
|
||||
public void save() {
|
||||
saveCallback.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import lombok.Getter;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
/**
|
||||
* Bundle of the mutable, savable game state for one playthrough: the
|
||||
* loaded {@link World}, the {@link Player}, world {@link GameState} flags,
|
||||
* {@link QuestLog} progress, the turn counter, and the bound save-slot name.
|
||||
* Save/load read and write exactly this object.
|
||||
*/
|
||||
@Getter
|
||||
public class GameSession {
|
||||
|
||||
private final World world;
|
||||
private final Player player;
|
||||
private final GameState state = new GameState();
|
||||
private final QuestLog questLog = new QuestLog();
|
||||
private final String slotName;
|
||||
private int turn;
|
||||
|
||||
public GameSession(World world, Player player, String slotName) {
|
||||
this.world = world;
|
||||
this.player = player;
|
||||
this.slotName = slotName;
|
||||
}
|
||||
|
||||
public void setTurn(int turn) {
|
||||
this.turn = turn;
|
||||
}
|
||||
|
||||
public void incrementTurn() {
|
||||
this.turn++;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
@@ -24,4 +25,10 @@ public class GameState {
|
||||
public Set<String> all() {
|
||||
return Collections.unmodifiableSet(flags);
|
||||
}
|
||||
|
||||
/** Replaces all flags with the given collection (used by load). */
|
||||
public void restore(Collection<String> newFlags) {
|
||||
flags.clear();
|
||||
flags.addAll(newFlags);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ public final class QuestEngine {
|
||||
ctx.getQuestLog().complete(q.id());
|
||||
ctx.getIo().print(StyledText.builder()
|
||||
.heading("★ Quest complete: ").plain(q.title()).build());
|
||||
ctx.save();
|
||||
}
|
||||
|
||||
public static QuestView viewOf(GameContext ctx) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -46,4 +47,12 @@ public class QuestLog {
|
||||
public Set<String> completed() {
|
||||
return Collections.unmodifiableSet(completed);
|
||||
}
|
||||
|
||||
/** Replaces all progress with the given stage map and completed set (load). */
|
||||
public void restore(Map<String, Integer> stages, Collection<String> completedIds) {
|
||||
stageIndex.clear();
|
||||
stageIndex.putAll(stages);
|
||||
completed.clear();
|
||||
completed.addAll(completedIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ public class ConsoleIO implements GameIO {
|
||||
|
||||
private final BufferedReader in;
|
||||
private final PrintStream out;
|
||||
private final boolean useColor;
|
||||
private final GlyphMode glyphs;
|
||||
private boolean useColor;
|
||||
private GlyphMode glyphs;
|
||||
|
||||
public ConsoleIO() {
|
||||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
|
||||
@@ -49,22 +49,33 @@ public class ConsoleIO implements GameIO {
|
||||
this.in = in;
|
||||
this.out = out;
|
||||
this.glyphs = glyphs;
|
||||
this.useColor = switch (colorMode) {
|
||||
setColorMode(colorMode);
|
||||
}
|
||||
|
||||
/** Re-resolves colour emission from the given mode (AUTO checks the console). */
|
||||
public void setColorMode(ColorMode mode) {
|
||||
this.useColor = switch (mode) {
|
||||
case ON -> true;
|
||||
case OFF -> false;
|
||||
case AUTO -> System.console() != null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Switches the frame/glyph fidelity tier live. */
|
||||
public void setGlyphMode(GlyphMode mode) {
|
||||
this.glyphs = mode;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String readLine() {
|
||||
out.print("> ");
|
||||
out.flush();
|
||||
try {
|
||||
String line = in.readLine();
|
||||
return line == null ? "" : line;
|
||||
// null propagates as true EOF (ends the game loop); "" stays "" (blank input re-prompts).
|
||||
return in.readLine();
|
||||
} catch (IOException e) {
|
||||
return "";
|
||||
// Treat a stream failure as EOF so input ends cleanly rather than spinning.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import thb.jeanluc.adventure.io.text.Renderings;
|
||||
import thb.jeanluc.adventure.io.text.RoomView;
|
||||
import thb.jeanluc.adventure.io.text.StyledText;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Bidirectional channel between the engine and the player. Commands emit
|
||||
* semantic, role-styled output via {@link #print(StyledText)} (the primitive)
|
||||
@@ -56,4 +58,38 @@ public interface GameIO {
|
||||
default void showQuests(QuestView view) {
|
||||
print(QuestText.render(view));
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents a titled list of options and returns the 0-based index of the
|
||||
* player's choice. Default (console) prints a numbered list and reads a
|
||||
* number, re-prompting on invalid input. EOF / empty input returns the
|
||||
* LAST option (menus place the safe/cancel option last). Frontends with
|
||||
* native widgets (e.g. buttons) may override.
|
||||
*
|
||||
* @param title heading shown above the options
|
||||
* @param options non-empty list of option labels
|
||||
* @return 0-based index into {@code options}
|
||||
*/
|
||||
default int choose(String title, List<String> options) {
|
||||
StyledText.Builder b = StyledText.builder().heading(title).plain("\n");
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
b.plain(" " + (i + 1) + ") " + options.get(i) + "\n");
|
||||
}
|
||||
print(b.build());
|
||||
while (true) {
|
||||
String line = readLine();
|
||||
if (line == null || line.isBlank()) {
|
||||
return options.size() - 1;
|
||||
}
|
||||
try {
|
||||
int n = Integer.parseInt(line.trim());
|
||||
if (n >= 1 && n <= options.size()) {
|
||||
return n - 1;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// fall through to re-prompt
|
||||
}
|
||||
write("Please enter a number between 1 and " + options.size() + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,11 @@ import thb.jeanluc.adventure.io.text.StyledText;
|
||||
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
@@ -342,6 +346,51 @@ public class SwingIO implements GameIO {
|
||||
// The GUI map panel is always visible; nothing extra to do on the 'map' command.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int choose(String title, List<String> options) {
|
||||
// Same EDT↔worker handoff as readLine: the worker blocks on take(),
|
||||
// the EDT (button click / window close) offers the chosen index.
|
||||
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
JDialog dialog = new JDialog(frame, title, true);
|
||||
JPanel panel = new JPanel();
|
||||
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
|
||||
panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
||||
panel.add(new JLabel(title));
|
||||
panel.add(Box.createVerticalStrut(8));
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
int idx = i;
|
||||
JButton b = new JButton(options.get(i));
|
||||
b.setAlignmentX(0f);
|
||||
b.addActionListener(e -> {
|
||||
dialog.dispose();
|
||||
picked.offer(idx);
|
||||
});
|
||||
panel.add(b);
|
||||
panel.add(Box.createVerticalStrut(4));
|
||||
}
|
||||
// Closing the dialog counts as the last (safe/cancel) option.
|
||||
dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
|
||||
dialog.addWindowListener(new java.awt.event.WindowAdapter() {
|
||||
@Override
|
||||
public void windowClosing(java.awt.event.WindowEvent e) {
|
||||
dialog.dispose();
|
||||
picked.offer(options.size() - 1);
|
||||
}
|
||||
});
|
||||
dialog.setContentPane(panel);
|
||||
dialog.pack();
|
||||
dialog.setLocationRelativeTo(frame);
|
||||
dialog.setVisible(true);
|
||||
});
|
||||
try {
|
||||
return picked.take();
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return options.size() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void addList(List<Span> spans, List<String> xs, Style st) {
|
||||
for (int i = 0; i < xs.size(); i++) {
|
||||
if (i > 0) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Frontend-agnostic main menu, slot picker, and new-game name prompt. */
|
||||
public final class MainMenu {
|
||||
|
||||
private MainMenu() {
|
||||
}
|
||||
|
||||
/** Shows the main menu and returns the chosen action. */
|
||||
public static MenuAction show(GameIO io) {
|
||||
int i = io.choose("HAUNTED MANOR",
|
||||
List.of("New Game", "Load Game", "Settings", "Quit"));
|
||||
return MenuAction.values()[i];
|
||||
}
|
||||
|
||||
/** Prompts for a new save name; blank input yields {@code defaultName}. */
|
||||
public static String promptName(GameIO io, String defaultName) {
|
||||
io.write("Name your save (Enter for \"" + defaultName + "\"):");
|
||||
String line = io.readLine();
|
||||
return (line == null || line.isBlank()) ? defaultName : line.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the player pick a save slot to load. Returns the chosen
|
||||
* {@link SaveSlotInfo}, or {@code null} if there are none or the player
|
||||
* picks "Back".
|
||||
*/
|
||||
public static SaveSlotInfo pickSlot(GameIO io, SaveService saves) {
|
||||
List<SaveSlotInfo> slots = saves.list();
|
||||
if (slots.isEmpty()) {
|
||||
io.write("No saved games.");
|
||||
return null;
|
||||
}
|
||||
List<String> labels = new ArrayList<>();
|
||||
for (SaveSlotInfo s : slots) {
|
||||
labels.add(s.slotName() + " (" + s.roomId() + ", turn " + s.turn() + ")");
|
||||
}
|
||||
labels.add("Back");
|
||||
int i = io.choose("LOAD GAME", labels);
|
||||
return i < slots.size() ? slots.get(i) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
/** Top-level main-menu choices, in display order. */
|
||||
public enum MenuAction {
|
||||
NEW_GAME, LOAD, SETTINGS, QUIT
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
import thb.jeanluc.adventure.io.GameIO;
|
||||
import thb.jeanluc.adventure.save.Settings;
|
||||
import thb.jeanluc.adventure.save.SettingsStore;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */
|
||||
public final class SettingsMenu {
|
||||
|
||||
private SettingsMenu() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loops the settings screen until the player picks "Back", persisting and
|
||||
* applying each change. Returns the final settings.
|
||||
*
|
||||
* @param io active IO
|
||||
* @param current starting settings
|
||||
* @param store where to persist
|
||||
* @param consoleIo the console IO to apply changes to live, or null if not console
|
||||
*/
|
||||
public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) {
|
||||
Settings s = current;
|
||||
while (true) {
|
||||
int i = io.choose("SETTINGS", List.of(
|
||||
"Colour: " + s.colorMode(),
|
||||
"Glyphs: " + s.glyphMode(),
|
||||
"Back"));
|
||||
if (i == 0) {
|
||||
s = new Settings(nextColor(s.colorMode()), s.glyphMode());
|
||||
} else if (i == 1) {
|
||||
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()));
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
store.save(s);
|
||||
apply(s, consoleIo);
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies colour/glyph settings to the console IO (no-op if null). */
|
||||
public static void apply(Settings s, ConsoleIO consoleIo) {
|
||||
if (consoleIo != null) {
|
||||
consoleIo.setColorMode(s.colorMode());
|
||||
consoleIo.setGlyphMode(s.glyphMode());
|
||||
}
|
||||
}
|
||||
|
||||
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
|
||||
return switch (m) {
|
||||
case AUTO -> ConsoleIO.ColorMode.ON;
|
||||
case ON -> ConsoleIO.ColorMode.OFF;
|
||||
case OFF -> ConsoleIO.ColorMode.AUTO;
|
||||
};
|
||||
}
|
||||
|
||||
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
|
||||
return switch (m) {
|
||||
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
|
||||
case ASCII -> ConsoleIO.GlyphMode.GLYPH;
|
||||
case GLYPH -> ConsoleIO.GlyphMode.UNICODE;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,14 @@ public class SwitchableItem extends Item {
|
||||
public boolean isOn() {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly sets the on/off state without running effects or printing.
|
||||
* Used only when restoring a saved game.
|
||||
*
|
||||
* @param state the state to restore
|
||||
*/
|
||||
public void setState(boolean state) {
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.game.QuestLog;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Converts between a live {@link GameSession} and a {@link SaveData} snapshot.
|
||||
* Pure (no disk): {@link #capture} reads a session; {@link #apply} overlays a
|
||||
* snapshot onto a session built from a freshly loaded world.
|
||||
*/
|
||||
public final class SaveCodec {
|
||||
|
||||
private SaveCodec() {
|
||||
}
|
||||
|
||||
/** Reads the mutable state of {@code session} into a {@link SaveData}. */
|
||||
public static SaveData capture(GameSession session) {
|
||||
World w = session.getWorld();
|
||||
Player p = session.getPlayer();
|
||||
|
||||
Map<String, List<String>> roomItems = new LinkedHashMap<>();
|
||||
for (Room room : w.getRooms().values()) {
|
||||
if (!room.getItems().isEmpty()) {
|
||||
roomItems.put(room.getId(), new ArrayList<>(room.getItems().keySet()));
|
||||
}
|
||||
}
|
||||
Map<String, Boolean> switches = new LinkedHashMap<>();
|
||||
for (Item item : w.getItems().values()) {
|
||||
if (item instanceof SwitchableItem sw) {
|
||||
switches.put(sw.getId(), sw.isOn());
|
||||
}
|
||||
}
|
||||
Map<String, Integer> stages = new LinkedHashMap<>();
|
||||
QuestLog log = session.getQuestLog();
|
||||
for (String id : log.active()) {
|
||||
stages.put(id, log.stageIndex(id));
|
||||
}
|
||||
|
||||
return new SaveData(
|
||||
SaveData.CURRENT_VERSION,
|
||||
w.getTitle(),
|
||||
session.getSlotName(),
|
||||
System.currentTimeMillis(),
|
||||
session.getTurn(),
|
||||
p.getCurrentRoom().getId(),
|
||||
new ArrayList<>(p.getVisitedRoomIds()),
|
||||
p.getGold(),
|
||||
new ArrayList<>(p.getInventory().keySet()),
|
||||
new ArrayList<>(session.getState().all()),
|
||||
stages,
|
||||
new ArrayList<>(log.completed()),
|
||||
roomItems,
|
||||
switches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays {@code data} onto {@code session} (whose world was just loaded
|
||||
* from YAML). Throws {@link SaveException} if the save references ids that
|
||||
* no longer exist in the current world.
|
||||
*/
|
||||
public static void apply(SaveData data, GameSession session) {
|
||||
World w = session.getWorld();
|
||||
Player p = session.getPlayer();
|
||||
|
||||
// 1. Clear all item placements, then redistribute from the save.
|
||||
for (Room room : w.getRooms().values()) {
|
||||
room.getItems().clear();
|
||||
}
|
||||
p.getInventory().clear();
|
||||
for (Map.Entry<String, List<String>> e : data.roomItemIds().entrySet()) {
|
||||
Room room = requireRoom(w, e.getKey());
|
||||
for (String itemId : e.getValue()) {
|
||||
room.addItem(requireItem(w, itemId));
|
||||
}
|
||||
}
|
||||
for (String itemId : data.inventoryItemIds()) {
|
||||
p.addItem(requireItem(w, itemId));
|
||||
}
|
||||
|
||||
// 2. Switch states.
|
||||
for (Map.Entry<String, Boolean> e : data.switchStates().entrySet()) {
|
||||
if (requireItem(w, e.getKey()) instanceof SwitchableItem sw) {
|
||||
sw.setState(e.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Player position + visited + gold.
|
||||
p.getVisitedRoomIds().clear();
|
||||
p.getVisitedRoomIds().addAll(data.visitedRoomIds());
|
||||
p.setCurrentRoom(requireRoom(w, data.currentRoomId()));
|
||||
p.setGold(data.gold());
|
||||
|
||||
// 4. Flags, quests, turn.
|
||||
session.getState().restore(data.flags());
|
||||
session.getQuestLog().restore(data.questStages(), data.questCompleted());
|
||||
session.setTurn(data.turn());
|
||||
}
|
||||
|
||||
private static Room requireRoom(World w, String id) {
|
||||
Room r = w.getRooms().get(id);
|
||||
if (r == null) {
|
||||
throw new SaveException("Save refers to unknown room '" + id
|
||||
+ "'. The game content may have changed.");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
private static Item requireItem(World w, String id) {
|
||||
Item i = w.getItems().get(id);
|
||||
if (i == null) {
|
||||
throw new SaveException("Save refers to unknown item '" + id
|
||||
+ "'. The game content may have changed.");
|
||||
}
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JSON-serialised snapshot of the mutable game state — a delta laid over the
|
||||
* world freshly loaded from YAML. Header fields ({@code slotName}, room,
|
||||
* {@code turn}, {@code savedAtEpochMillis}) double as the slot-list metadata.
|
||||
*/
|
||||
public record SaveData(
|
||||
int schemaVersion,
|
||||
String worldTitle,
|
||||
String slotName,
|
||||
long savedAtEpochMillis,
|
||||
int turn,
|
||||
String currentRoomId,
|
||||
List<String> visitedRoomIds,
|
||||
int gold,
|
||||
List<String> inventoryItemIds,
|
||||
List<String> flags,
|
||||
Map<String, Integer> questStages,
|
||||
List<String> questCompleted,
|
||||
Map<String, List<String>> roomItemIds,
|
||||
Map<String, Boolean> switchStates
|
||||
) {
|
||||
/** Current on-disk schema version. */
|
||||
public static final int CURRENT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Compact canonical constructor: normalizes any null collection component
|
||||
* to an empty immutable collection so a truncated/hand-edited save JSON
|
||||
* degrades gracefully instead of NPE-ing in {@link SaveCodec#apply}.
|
||||
*/
|
||||
public SaveData {
|
||||
if (visitedRoomIds == null) visitedRoomIds = List.of();
|
||||
if (inventoryItemIds == null) inventoryItemIds = List.of();
|
||||
if (flags == null) flags = List.of();
|
||||
if (questStages == null) questStages = Map.of();
|
||||
if (questCompleted == null) questCompleted = List.of();
|
||||
if (roomItemIds == null) roomItemIds = Map.of();
|
||||
if (switchStates == null) switchStates = Map.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
/** Thrown when a save cannot be written/read; carries a player-facing message. */
|
||||
public class SaveException extends RuntimeException {
|
||||
|
||||
public SaveException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public SaveException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/** Player-facing message (the constructor message is already user-friendly). */
|
||||
public String getUserMessage() {
|
||||
return getMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Reads and writes save slots as JSON files in a directory (default
|
||||
* {@code ./saves}). One slot = one {@code <slug>.json}. Loading reloads the
|
||||
* world from YAML and overlays the snapshot via {@link SaveCodec}.
|
||||
*/
|
||||
@Slf4j
|
||||
public class SaveService {
|
||||
|
||||
private static final String SETTINGS_FILENAME = "settings.json";
|
||||
|
||||
private final Path dir;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
/** Uses the default {@code ./saves} directory. */
|
||||
public SaveService() {
|
||||
this(Path.of("saves"));
|
||||
}
|
||||
|
||||
public SaveService(Path dir) {
|
||||
this.dir = dir;
|
||||
}
|
||||
|
||||
/** Slug used for the on-disk filename of a slot name. */
|
||||
public static String slug(String slotName) {
|
||||
String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_");
|
||||
if (s.isBlank()) {
|
||||
return "save";
|
||||
}
|
||||
// Never let a user-named save collide with the settings file.
|
||||
return s.equals("settings") ? "settings_" : s;
|
||||
}
|
||||
|
||||
/** Writes the session to {@code <slug>.json} atomically. */
|
||||
public void save(GameSession session) {
|
||||
SaveData data = SaveCodec.capture(session);
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
Path target = dir.resolve(slug(session.getSlotName()) + ".json");
|
||||
Path tmp = dir.resolve(slug(session.getSlotName()) + ".json.tmp");
|
||||
mapper.writerWithDefaultPrettyPrinter().writeValue(tmp.toFile(), data);
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException atomicFailed) {
|
||||
try {
|
||||
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException fallbackFailed) {
|
||||
Files.deleteIfExists(tmp);
|
||||
throw fallbackFailed;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new SaveException("Could not write save '" + session.getSlotName() + "': " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists all readable slots, newest first; skips unreadable files. */
|
||||
public List<SaveSlotInfo> list() {
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return List.of();
|
||||
}
|
||||
List<SaveSlotInfo> out = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
for (Path f : files
|
||||
.filter(p -> p.toString().endsWith(".json"))
|
||||
.filter(p -> !p.getFileName().toString().equals(SETTINGS_FILENAME))
|
||||
.toList()) {
|
||||
try {
|
||||
SaveData d = mapper.readValue(f.toFile(), SaveData.class);
|
||||
String slug = f.getFileName().toString().replaceFirst("\\.json$", "");
|
||||
out.add(new SaveSlotInfo(slug, d.slotName(), d.currentRoomId(),
|
||||
d.turn(), d.savedAtEpochMillis()));
|
||||
} catch (IOException badFile) {
|
||||
log.warn("Skipping unreadable save file {}", f, badFile);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not list save directory {}", dir, e);
|
||||
}
|
||||
out.sort(Comparator.comparingLong(SaveSlotInfo::savedAtEpochMillis).reversed());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Loads a slot by slug: read JSON, reload world, overlay snapshot. */
|
||||
public GameSession load(String slug) {
|
||||
Path file = dir.resolve(slug + ".json");
|
||||
SaveData data;
|
||||
try {
|
||||
data = mapper.readValue(file.toFile(), SaveData.class);
|
||||
} catch (IOException e) {
|
||||
throw new SaveException("Could not read save '" + slug + "': " + e.getMessage(), e);
|
||||
}
|
||||
if (data.schemaVersion() != SaveData.CURRENT_VERSION) {
|
||||
throw new SaveException("Save '" + slug + "' uses an incompatible version ("
|
||||
+ data.schemaVersion() + "); expected " + SaveData.CURRENT_VERSION + ".");
|
||||
}
|
||||
WorldLoader.LoadResult fresh = new WorldLoader().load();
|
||||
GameSession session = new GameSession(fresh.world(), fresh.player(), data.slotName());
|
||||
SaveCodec.apply(data, session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
/** Lightweight metadata for one save slot, shown in the Load menu. */
|
||||
public record SaveSlotInfo(String slug, String slotName, String roomId,
|
||||
int turn, long savedAtEpochMillis) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
|
||||
/** Persisted user preferences (minimal: colour + glyph fidelity). */
|
||||
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
|
||||
|
||||
public static Settings defaults() {
|
||||
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** Loads/saves {@link Settings} as JSON (default {@code saves/settings.json}). */
|
||||
@Slf4j
|
||||
public class SettingsStore {
|
||||
|
||||
private final Path file;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
public SettingsStore() {
|
||||
this(Path.of("saves", "settings.json"));
|
||||
}
|
||||
|
||||
public SettingsStore(Path file) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
/** Returns persisted settings, or {@link Settings#defaults()} on any problem. */
|
||||
public Settings load() {
|
||||
if (!Files.isRegularFile(file)) {
|
||||
return Settings.defaults();
|
||||
}
|
||||
try {
|
||||
return mapper.readValue(file.toFile(), Settings.class);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not read settings {}, using defaults", file, e);
|
||||
return Settings.defaults();
|
||||
}
|
||||
}
|
||||
|
||||
/** Persists settings; logs and swallows failures (non-fatal). */
|
||||
public void save(Settings settings) {
|
||||
try {
|
||||
if (file.getParent() != null) {
|
||||
Files.createDirectories(file.getParent());
|
||||
}
|
||||
mapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), settings);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not write settings {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package thb.jeanluc.adventure.command;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import thb.jeanluc.adventure.command.impl.MenuCommand;
|
||||
import thb.jeanluc.adventure.command.impl.SaveCommand;
|
||||
import thb.jeanluc.adventure.game.Game;
|
||||
import thb.jeanluc.adventure.game.GameContext;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SaveMenuCommandTest {
|
||||
|
||||
private GameContext ctx(String slot) {
|
||||
WorldLoader.LoadResult r = new WorldLoader().load();
|
||||
return new GameContext(new GameSession(r.world(), r.player(), slot), new TestIO());
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveCommandWritesSlotAndReportsSuccess(@TempDir Path dir) {
|
||||
SaveService svc = new SaveService(dir);
|
||||
GameContext ctx = ctx("game-a");
|
||||
new SaveCommand(svc).execute(ctx, List.of());
|
||||
assertThat(svc.list()).extracting(s -> s.slotName()).containsExactly("game-a");
|
||||
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Game saved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void menuCommandSavesThenStopsLoop(@TempDir Path dir) {
|
||||
SaveService svc = new SaveService(dir);
|
||||
GameContext ctx = ctx("game-b");
|
||||
Game game = new Game(ctx, new CommandRegistry(), new CommandParser());
|
||||
MenuCommand menu = new MenuCommand(svc);
|
||||
menu.bind(game);
|
||||
menu.execute(ctx, List.of());
|
||||
assertThat(svc.list()).hasSize(1);
|
||||
// loop is stopped: run() must return without consuming queued input
|
||||
((TestIO) ctx.getIo()).enqueue("look");
|
||||
game.run();
|
||||
assertThat(ctx.getIo().readLine()).isEqualTo("look"); // sentinel still unread
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GameSessionTest {
|
||||
|
||||
private GameSession session() {
|
||||
Room k = new Room("k", "Kitchen", "d");
|
||||
World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w");
|
||||
return new GameSession(w, new Player(k, 0), "slot-a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesStateAndIncrementsTurn() {
|
||||
GameSession s = session();
|
||||
assertThat(s.getSlotName()).isEqualTo("slot-a");
|
||||
assertThat(s.getTurn()).isZero();
|
||||
s.incrementTurn();
|
||||
s.incrementTurn();
|
||||
assertThat(s.getTurn()).isEqualTo(2);
|
||||
s.setTurn(10);
|
||||
assertThat(s.getTurn()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextDelegatesToSession() {
|
||||
GameSession s = session();
|
||||
GameContext ctx = new GameContext(s, new TestIO());
|
||||
assertThat(ctx.getWorld()).isSameAs(s.getWorld());
|
||||
assertThat(ctx.getPlayer()).isSameAs(s.getPlayer());
|
||||
assertThat(ctx.getState()).isSameAs(s.getState());
|
||||
assertThat(ctx.getQuestLog()).isSameAs(s.getQuestLog());
|
||||
assertThat(ctx.getSession()).isSameAs(s);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyConstructorBuildsFreshSession() {
|
||||
Room k = new Room("k", "Kitchen", "d");
|
||||
World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w");
|
||||
GameContext ctx = new GameContext(w, new Player(k, 0), new TestIO());
|
||||
assertThat(ctx.getSession()).isNotNull();
|
||||
assertThat(ctx.getSession().getTurn()).isZero();
|
||||
assertThat(ctx.getWorld()).isSameAs(w);
|
||||
assertThat(ctx.getPlayer().getCurrentRoom()).isSameAs(k);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import thb.jeanluc.adventure.command.CommandParser;
|
||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||
import thb.jeanluc.adventure.command.impl.GoCommand;
|
||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||
import thb.jeanluc.adventure.command.impl.MenuCommand;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -19,6 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GameTest {
|
||||
|
||||
@TempDir
|
||||
Path savesDir;
|
||||
|
||||
@Test
|
||||
void run_dispatchesCommands_andQuitsCleanly() {
|
||||
Room kitchen = new Room("kitchen", "Kitchen", "kd");
|
||||
@@ -36,7 +42,7 @@ class GameTest {
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
registry.register(new GoCommand(), "go");
|
||||
registry.register(new LookCommand(), "look");
|
||||
QuitCommand quit = new QuitCommand();
|
||||
MenuCommand quit = new MenuCommand(new SaveService(savesDir));
|
||||
registry.register(quit, "quit");
|
||||
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
@@ -44,7 +50,7 @@ class GameTest {
|
||||
game.run();
|
||||
|
||||
assertThat(player.getCurrentRoom()).isEqualTo(hallway);
|
||||
assertThat(io.allOutput()).contains("Goodbye");
|
||||
assertThat(io.allOutput()).contains("Returning to the main menu");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,7 +61,7 @@ class GameTest {
|
||||
GameContext ctx = new GameContext(new World(Map.of("r", r), Map.of(), Map.of(), "t", "w"), player, io);
|
||||
|
||||
CommandRegistry registry = new CommandRegistry();
|
||||
QuitCommand quit = new QuitCommand();
|
||||
MenuCommand quit = new MenuCommand(new SaveService(savesDir));
|
||||
registry.register(quit, "quit");
|
||||
Game game = new Game(ctx, registry, new CommandParser());
|
||||
quit.bind(game);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.model.Condition;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Quest;
|
||||
import thb.jeanluc.adventure.model.QuestStage;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class QuestAutosaveTest {
|
||||
|
||||
@Test
|
||||
void questCompletionTriggersSaveCallback() {
|
||||
Quest q = new Quest("q", "Quest", true, List.of(
|
||||
new QuestStage("Do it", List.of(new Condition(Condition.Type.FLAG, "a")), List.of())),
|
||||
List.of());
|
||||
Room k = new Room("k", "K", "d");
|
||||
World w = new World(Map.of("k", k), Map.of(), Map.of(), "t", "w", Map.of("q", q));
|
||||
GameContext ctx = new GameContext(new GameSession(w, new Player(k, 0), "slot"), new TestIO());
|
||||
AtomicInteger saves = new AtomicInteger();
|
||||
ctx.setSaveCallback(saves::incrementAndGet);
|
||||
|
||||
QuestEngine.tick(ctx); // starts quest; not complete yet
|
||||
assertThat(saves.get()).isZero();
|
||||
ctx.getState().set("a");
|
||||
QuestEngine.tick(ctx); // completes quest → autosave
|
||||
assertThat(saves.get()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package thb.jeanluc.adventure.game;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RestoreHooksTest {
|
||||
|
||||
@Test
|
||||
void gameStateRestoreReplacesFlags() {
|
||||
GameState s = new GameState();
|
||||
s.set("old");
|
||||
s.restore(List.of("a", "b"));
|
||||
assertThat(s.isSet("old")).isFalse();
|
||||
assertThat(s.all()).containsExactlyInAnyOrder("a", "b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void questLogRestoreReplacesProgress() {
|
||||
QuestLog q = new QuestLog();
|
||||
q.start("stale");
|
||||
q.restore(Map.of("active", 2), Set.of("done"));
|
||||
assertThat(q.isActive("stale")).isFalse();
|
||||
assertThat(q.stageIndex("active")).isEqualTo(2);
|
||||
assertThat(q.isCompleted("done")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void switchableSetStateDoesNotRunEffects() {
|
||||
SwitchableItem lamp = SwitchableItem.builder()
|
||||
.id("lamp").name("Lamp").description("d").light(true)
|
||||
.onText("on").offText("off").state(false).effects(List.of())
|
||||
.build();
|
||||
lamp.setState(true);
|
||||
assertThat(lamp.isOn()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ChooseDefaultTest {
|
||||
|
||||
@Test
|
||||
void returnsZeroBasedIndexOfValidChoice() {
|
||||
TestIO io = new TestIO().enqueue("2");
|
||||
int i = io.choose("Menu", List.of("A", "B", "C"));
|
||||
assertThat(i).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rePromptsOnInvalidThenAccepts() {
|
||||
TestIO io = new TestIO().enqueue("9").enqueue("x").enqueue("1");
|
||||
int i = io.choose("Menu", List.of("A", "B"));
|
||||
assertThat(i).isZero();
|
||||
assertThat(io.allOutput()).contains("between 1 and 2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void eofReturnsLastOption() {
|
||||
TestIO io = new TestIO(); // empty queue → readLine() returns null
|
||||
int i = io.choose("Menu", List.of("Play", "Quit"));
|
||||
assertThat(i).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,25 @@ class ConsoleIORenderTest {
|
||||
assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off");
|
||||
}
|
||||
|
||||
@Test
|
||||
void readLine_returnsNullOnEof() {
|
||||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
|
||||
ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")), ps,
|
||||
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
|
||||
assertThat(io.readLine()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void readLine_returnsEmptyStringForBlankLineThenNullAtEof() {
|
||||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
|
||||
ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("\n")), ps,
|
||||
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
|
||||
assertThat(io.readLine()).isEqualTo(""); // blank interactive line, re-prompt
|
||||
assertThat(io.readLine()).isNull(); // stream now exhausted → EOF
|
||||
}
|
||||
|
||||
@Test
|
||||
void asciiGlyphMode_usesPlusDashFrame() {
|
||||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package thb.jeanluc.adventure.io;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.io.StringReader;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ConsoleIOSettingsTest {
|
||||
|
||||
@Test
|
||||
void colorSetterTogglesAnsiEmission() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ConsoleIO io = new ConsoleIO(new BufferedReader(new StringReader("")),
|
||||
new PrintStream(out), ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
|
||||
io.setColorMode(ConsoleIO.ColorMode.ON);
|
||||
io.print(thb.jeanluc.adventure.io.text.StyledText.builder().heading("Hi").build());
|
||||
assertThat(out.toString()).contains("["); // ANSI escape present
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package thb.jeanluc.adventure.menu;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.io.TestIO;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
import thb.jeanluc.adventure.save.SaveService;
|
||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||
import thb.jeanluc.adventure.save.Settings;
|
||||
import thb.jeanluc.adventure.save.SettingsStore;
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MenuTest {
|
||||
|
||||
@Test
|
||||
void mainMenuMapsChoiceToAction() {
|
||||
// options order: New Game(1) / Load(2) / Settings(3) / Quit(4)
|
||||
assertThat(MainMenu.show(new TestIO().enqueue("1"))).isEqualTo(MenuAction.NEW_GAME);
|
||||
assertThat(MainMenu.show(new TestIO().enqueue("2"))).isEqualTo(MenuAction.LOAD);
|
||||
assertThat(MainMenu.show(new TestIO().enqueue("3"))).isEqualTo(MenuAction.SETTINGS);
|
||||
assertThat(MainMenu.show(new TestIO().enqueue("4"))).isEqualTo(MenuAction.QUIT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void promptNameUsesDefaultOnBlank() {
|
||||
assertThat(MainMenu.promptName(new TestIO().enqueue(""), "Manor")).isEqualTo("Manor");
|
||||
assertThat(MainMenu.promptName(new TestIO().enqueue("Spooky"), "Manor")).isEqualTo("Spooky");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickSlotReturnsNullWhenEmpty(@TempDir Path dir) {
|
||||
TestIO io = new TestIO();
|
||||
assertThat(MainMenu.pickSlot(io, new SaveService(dir))).isNull();
|
||||
assertThat(io.allOutput()).contains("No saved games");
|
||||
}
|
||||
|
||||
private void saveSlot(Path dir, String name) {
|
||||
var r = new WorldLoader().load();
|
||||
new SaveService(dir).save(new GameSession(r.world(), r.player(), name));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickSlotReturnsChosenSlot(@TempDir Path dir) {
|
||||
saveSlot(dir, "Alpha");
|
||||
TestIO io = new TestIO().enqueue("1");
|
||||
SaveSlotInfo result = MainMenu.pickSlot(io, new SaveService(dir));
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.slotName()).isEqualTo("Alpha");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickSlotReturnsNullOnBack(@TempDir Path dir) {
|
||||
saveSlot(dir, "Alpha");
|
||||
// one slot → options are [1: Alpha, 2: Back]
|
||||
TestIO io = new TestIO().enqueue("2");
|
||||
assertThat(MainMenu.pickSlot(io, new SaveService(dir))).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
|
||||
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||
// choose "Glyph mode" (assume option 2) → its toggle, then "Back" (last)
|
||||
TestIO io = new TestIO().enqueue("2").enqueue("3");
|
||||
Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO());
|
||||
// UNICODE default toggles to ASCII; persisted
|
||||
assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||
assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.model.Direction;
|
||||
import thb.jeanluc.adventure.model.Player;
|
||||
import thb.jeanluc.adventure.model.Room;
|
||||
import thb.jeanluc.adventure.model.World;
|
||||
import thb.jeanluc.adventure.model.item.Item;
|
||||
import thb.jeanluc.adventure.model.item.PlainItem;
|
||||
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SaveCodecTest {
|
||||
|
||||
/** Builds a fresh 2-room world: shovel in cellar, lamp (off) in kitchen. */
|
||||
private GameSession freshWorld(String slot) {
|
||||
Room kitchen = new Room("kitchen", "Kitchen", "k");
|
||||
Room cellar = new Room("cellar", "Cellar", "c");
|
||||
kitchen.addExit(Direction.SOUTH, cellar);
|
||||
Item shovel = PlainItem.builder().id("shovel").name("Shovel").description("d").light(false).build();
|
||||
SwitchableItem lamp = SwitchableItem.builder()
|
||||
.id("lamp").name("Lamp").description("d").light(true)
|
||||
.onText("on").offText("off").state(false).effects(List.of()).build();
|
||||
cellar.addItem(shovel);
|
||||
kitchen.addItem(lamp);
|
||||
Map<String, Item> items = new LinkedHashMap<>();
|
||||
items.put("shovel", shovel);
|
||||
items.put("lamp", lamp);
|
||||
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||
rooms.put("kitchen", kitchen);
|
||||
rooms.put("cellar", cellar);
|
||||
World w = new World(rooms, items, Map.of(), "Haunted Manor", "welcome");
|
||||
return new GameSession(w, new Player(kitchen, 0), slot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void captureThenApplyToFreshWorldReproducesState() {
|
||||
GameSession src = freshWorld("slot-a");
|
||||
// mutate: move to cellar, pick up shovel, light the lamp, set a flag, gold
|
||||
src.getPlayer().setCurrentRoom(src.getWorld().getRooms().get("cellar"));
|
||||
Item shovel = src.getWorld().getRooms().get("cellar").removeItem("shovel").orElseThrow();
|
||||
src.getPlayer().addItem(shovel);
|
||||
((SwitchableItem) src.getWorld().getItems().get("lamp")).setState(true);
|
||||
src.getState().set("power_on");
|
||||
src.getPlayer().setGold(5);
|
||||
src.setTurn(12);
|
||||
|
||||
SaveData data = SaveCodec.capture(src);
|
||||
|
||||
GameSession dst = freshWorld("slot-a");
|
||||
SaveCodec.apply(data, dst);
|
||||
|
||||
assertThat(dst.getPlayer().getCurrentRoom().getId()).isEqualTo("cellar");
|
||||
assertThat(dst.getPlayer().hasItem("shovel")).isTrue();
|
||||
assertThat(dst.getWorld().getRooms().get("cellar").findItem("shovel")).isEmpty();
|
||||
assertThat(((SwitchableItem) dst.getWorld().getItems().get("lamp")).isOn()).isTrue();
|
||||
assertThat(dst.getState().isSet("power_on")).isTrue();
|
||||
assertThat(dst.getPlayer().getGold()).isEqualTo(5);
|
||||
assertThat(dst.getTurn()).isEqualTo(12);
|
||||
assertThat(dst.getPlayer().getVisitedRoomIds()).contains("kitchen", "cellar");
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyRejectsUnknownRoom() {
|
||||
GameSession src = freshWorld("slot-a");
|
||||
SaveData data = SaveCodec.capture(src);
|
||||
SaveData broken = new SaveData(
|
||||
data.schemaVersion(), data.worldTitle(), data.slotName(),
|
||||
data.savedAtEpochMillis(), data.turn(), "ballroom",
|
||||
data.visitedRoomIds(), data.gold(), data.inventoryItemIds(),
|
||||
data.flags(), data.questStages(), data.questCompleted(),
|
||||
data.roomItemIds(), data.switchStates());
|
||||
org.junit.jupiter.api.Assertions.assertThrows(SaveException.class,
|
||||
() -> SaveCodec.apply(broken, freshWorld("slot-a")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void applyToleratesMissingCollections() {
|
||||
// Construct a SaveData with null collections (simulates a hand-edited / truncated save).
|
||||
// The compact constructor should normalize nulls to empty collections.
|
||||
SaveData data = new SaveData(
|
||||
SaveData.CURRENT_VERSION, "Haunted Manor", "slot-a",
|
||||
System.currentTimeMillis(), 0,
|
||||
"kitchen",
|
||||
null, // visitedRoomIds
|
||||
0,
|
||||
null, // inventoryItemIds
|
||||
null, // flags
|
||||
null, // questStages
|
||||
null, // questCompleted
|
||||
null, // roomItemIds
|
||||
null // switchStates
|
||||
);
|
||||
|
||||
GameSession dst = freshWorld("slot-a");
|
||||
// Should not throw NPE; world items remain in their default positions or cleared
|
||||
org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> SaveCodec.apply(data, dst));
|
||||
// Post-condition: applying a save with null/empty roomItemIds clears all room item tables.
|
||||
assertThat(dst.getWorld().getRooms().get("kitchen").findItem("lamp")).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SaveDataTest {
|
||||
|
||||
@Test
|
||||
void roundTripsThroughJackson() throws Exception {
|
||||
SaveData data = new SaveData(
|
||||
1, "Haunted Manor", "slot-a", 1700000000000L, 7,
|
||||
"library", List.of("kitchen", "library"), 3,
|
||||
List.of("key"), List.of("power_on"),
|
||||
Map.of("restore_power", 1), List.of("intro"),
|
||||
Map.of("cellar", List.of("shovel")), Map.of("lamp", true));
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
String json = mapper.writeValueAsString(data);
|
||||
SaveData back = mapper.readValue(json, SaveData.class);
|
||||
assertThat(back).isEqualTo(data);
|
||||
assertThat(back.currentRoomId()).isEqualTo("library");
|
||||
assertThat(back.switchStates()).containsEntry("lamp", true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import thb.jeanluc.adventure.game.GameSession;
|
||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SaveServiceTest {
|
||||
|
||||
private GameSession freshSession(String slot) {
|
||||
WorldLoader.LoadResult r = new WorldLoader().load();
|
||||
return new GameSession(r.world(), r.player(), slot);
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveThenLoadReproducesState(@TempDir Path dir) {
|
||||
SaveService svc = new SaveService(dir);
|
||||
GameSession s = freshSession("my-game");
|
||||
s.getState().set("power_on");
|
||||
s.getPlayer().setGold(4);
|
||||
s.setTurn(9);
|
||||
svc.save(s);
|
||||
|
||||
GameSession loaded = svc.load("my-game");
|
||||
assertThat(loaded.getState().isSet("power_on")).isTrue();
|
||||
assertThat(loaded.getPlayer().getGold()).isEqualTo(4);
|
||||
assertThat(loaded.getTurn()).isEqualTo(9);
|
||||
assertThat(loaded.getSlotName()).isEqualTo("my-game");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReturnsSlotMetadata(@TempDir Path dir) {
|
||||
SaveService svc = new SaveService(dir);
|
||||
GameSession s = freshSession("Alpha Run");
|
||||
s.setTurn(3);
|
||||
svc.save(s);
|
||||
|
||||
List<SaveSlotInfo> slots = svc.list();
|
||||
assertThat(slots).hasSize(1);
|
||||
assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run");
|
||||
assertThat(slots.getFirst().slug()).isEqualTo("alpha_run");
|
||||
assertThat(slots.getFirst().turn()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkipsCorruptFiles(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("broken.json"), "{ not json");
|
||||
assertThat(new SaveService(dir).list()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listExcludesSettingsFile(@TempDir Path dir) throws Exception {
|
||||
SaveService svc = new SaveService(dir);
|
||||
GameSession s = freshSession("Real Save");
|
||||
svc.save(s);
|
||||
Files.writeString(dir.resolve("settings.json"), "{\"color\":\"on\"}");
|
||||
|
||||
List<SaveSlotInfo> slots = svc.list();
|
||||
assertThat(slots).hasSize(1);
|
||||
assertThat(slots.getFirst().slotName()).isEqualTo("Real Save");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slugForSettingsDoesNotCollideWithSettingsFile() {
|
||||
assertThat(SaveService.slug("settings")).isNotEqualTo("settings");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadCorruptFileThrowsSaveException(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("bad.json"), "{ not json");
|
||||
SaveService svc = new SaveService(dir);
|
||||
assertThrows(SaveException.class, () -> svc.load("bad"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadRejectsIncompatibleVersion(@TempDir Path dir) throws Exception {
|
||||
// a syntactically valid SaveData JSON but with a future schemaVersion
|
||||
String json = "{\"schemaVersion\":999,\"worldTitle\":\"X\",\"slotName\":\"v\","
|
||||
+ "\"savedAtEpochMillis\":0,\"turn\":0,\"currentRoomId\":\"k\","
|
||||
+ "\"visitedRoomIds\":[],\"gold\":0,\"inventoryItemIds\":[],\"flags\":[],"
|
||||
+ "\"questStages\":{},\"questCompleted\":[],\"roomItemIds\":{},\"switchStates\":{}}";
|
||||
Files.writeString(dir.resolve("v.json"), json);
|
||||
SaveService svc = new SaveService(dir);
|
||||
assertThrows(SaveException.class, () -> svc.load("v"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package thb.jeanluc.adventure.save;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SettingsStoreTest {
|
||||
|
||||
@Test
|
||||
void savesAndLoads(@TempDir Path dir) {
|
||||
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||
store.save(new Settings(ConsoleIO.ColorMode.ON, ConsoleIO.GlyphMode.ASCII));
|
||||
Settings loaded = store.load();
|
||||
assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON);
|
||||
assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingFileReturnsDefaults(@TempDir Path dir) {
|
||||
Settings loaded = new SettingsStore(dir.resolve("nope.json")).load();
|
||||
assertThat(loaded).isEqualTo(Settings.defaults());
|
||||
}
|
||||
|
||||
@Test
|
||||
void corruptFileReturnsDefaults(@TempDir Path dir) throws IOException {
|
||||
Path file = dir.resolve("settings.json");
|
||||
Files.writeString(file, "{ not json");
|
||||
Settings loaded = new SettingsStore(file).load();
|
||||
assertThat(loaded).isEqualTo(Settings.defaults());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user