From 055a256b0348ec5f560a0f61b2eb904df2db6083 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:10:13 +0200 Subject: [PATCH 1/9] docs: spec for interactive start-of-game tutorial Interactive walkthrough layered on the real game loop via a TutorialGuide (optional in Game): core arc look->examine->take->inventory->use->go->go to, verb-based alias-aware completion (movement steps require an actual room change), closing tips, skippable via 'skip'. Data-driven tutorial.yaml + separate TutorialLoader (not in World). Shown only on New Game. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-01-tutorial-design.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/specs/2026-06-01-tutorial-design.md diff --git a/Semesterprojekt/docs/superpowers/specs/2026-06-01-tutorial-design.md b/Semesterprojekt/docs/superpowers/specs/2026-06-01-tutorial-design.md new file mode 100644 index 0000000..c7eb349 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-06-01-tutorial-design.md @@ -0,0 +1,254 @@ +# Spec: Interaktives Start-Tutorial + +Stand: 2026-06-01. Dritte der verbleibenden Mechaniken (nach `use X on Y` und +Pathfinding). Setzt das vom Nutzer gewünschte **interaktive Tutorial beim +Spielstart, abbrechbar per `skip`** um. Baut auf dem geteilten Game-Loop, +`CommandRegistry`/`CommandParser`, `GameIO` und dem datengetriebenen Loader auf. + +## 1. Kontext & Ziel + +Neue Spieler kennen die Befehle nicht. Ziel: ein **interaktiver Walkthrough** zu +Beginn eines **Neuen Spiels**, der den Spieler im **echten Startraum** Schritt für +Schritt die Kernbefehle ausführen lässt (er wartet, bis der jeweilige Befehl kam), +mit Bestätigung/Hinweisen, und am Ende die übrigen Befehle als Tipps nennt. +Jederzeit per `skip` überspringbar. Geladene Spielstände zeigen kein Tutorial. + +Bestätigte Entscheidungen (Brainstorming 2026-06-01): +- **Stil**: interaktiv (wait-for-it) im echten Spiel, nicht in einer Sandbox. +- **Kern-Bogen** (interaktiv, in Reihenfolge): `look` → `examine` → `take` → + `inventory` → `use` → `go` (Richtung) → `go to` (Pathfinding). Danach + **Abschluss-Tipps** für den Rest (read, drop, talk/give, use X on Y, map, + quests, save, menu). +- **Abschluss-Erkennung verb-basiert** (alias-fähig): jede gültige Nutzung des + erwarteten Verbs zählt; optional `minArgs`. +- **Datengetrieben**: `tutorial.yaml`; Logik (Verb-/Richtungs-Matching) im Code. +- **Häufigkeit**: bei jedem Neuen Spiel; `skip` überspringt; keine Persistenz. +- **Falscher (aber gültiger) Befehl**: wird **ausgeführt** (echtes Spiel) + sanfter + Hinweis, Schritt rückt **nicht** vor. +- **Platzierung**: **nach** Banner + Willkommen + erstem Raum-`look`. + +## 2. Scope + +**In Scope:** +- `TutorialGuide` (game), in den Game-Loop eingehängt (optional; null = kein Tutorial). +- `Tutorial`/`TutorialStep` (model) + `TutorialDto`/`TutorialStepDto` (loader/dto) + + `TutorialLoader` (optionales `/world/tutorial.yaml`, eigener Loader — nicht in `World`). +- `Game`: optionaler Guide (`setTutorialGuide`) + `begin`/`onCommand`/`skip`-Anbindung. +- `App.play(..., withTutorial)`: Guide nur beim Neuen Spiel bauen. +- `tutorial.yaml`: Intro, 7 Kern-Schritte, Abschluss-Tipps. +- Tests: Guide-Logik, Loader, Game-Integration. + +**Out of Scope:** +- Persistenz „Tutorial gesehen" / Settings-Toggle (eigene spätere Option). +- Sandbox-Welt / Zurücksetzen von Tutorial-Aktionen (es ist das echte Spiel). +- Erzwungenes `use X on Y` / `talk` etc. (nur in Abschluss-Tipps genannt). +- GUI-Sonderdarstellung (Tutorial nutzt die normale styled Ausgabe; `skip` per Eingabe). + +## 3. Datenmodell (model) + +```java +public record TutorialStep( + String instruction, // an den Spieler: was tun + String expect, // Verb-Name ("look"/"examine"/...) ODER "go_direction" / "go_to_room" + int minArgs, // Mindest-Argumentzahl (z.B. 1 für take/examine/use); 0 default + String confirm, // Bestätigung bei Erfolg + String hint // sanfter Hinweis bei gültigem-aber-falschem Befehl +) {} + +public record Tutorial(String intro, List steps, String closingTips) { + public Tutorial { + steps = steps == null ? List.of() : List.copyOf(steps); + } + public boolean isEmpty() { return steps.isEmpty(); } + public static Tutorial none() { return new Tutorial(null, List.of(), null); } +} +``` + +`tutorial.yaml` (ein Mapping, optional): +```yaml +intro: | + Welcome. Let me show you the ropes — type the commands as I describe them. + (Type 'skip' at any time to jump straight into the game.) +steps: + - instruction: "Look around the room: type 'look'." + expect: look + confirm: "Good — 'look' (or 'l') always re-describes where you are." + hint: "When you're ready, just type: look" + - instruction: "Examine something you see: 'examine ' (or 'x ')." + expect: examine + minArgs: 1 + confirm: "That's how you inspect items, exits and people up close." + hint: "Try examining something, e.g.: examine lamp" + - instruction: "Pick something up: 'take '." + expect: take + minArgs: 1 + confirm: "Taken. Items you carry go into your inventory." + hint: "Pick something up, e.g.: take lamp" + - instruction: "Check what you're carrying: 'inventory' (or 'i')." + expect: inventory + confirm: "That's your inventory." + hint: "Type: inventory" + - instruction: "Use an item: 'use ' (e.g. light the lamp)." + expect: use + minArgs: 1 + confirm: "Some items toggle, read or react when used." + hint: "Use something, e.g.: use lamp" + - instruction: "Move to another room: 'go ' (try 'go north')." + expect: go_direction + confirm: "You can move north/south/east/west between connected rooms." + hint: "Head somewhere, e.g.: go north" + - instruction: "Travel back to a room you've seen: 'go to '." + expect: go_to_room + confirm: "Nice — 'go to ' auto-walks to any room you've already visited." + hint: "Try: go to " +closingTips: | + That's the core. A few more you'll want: + read · drop · talk to · give to · + use on (combine things) · map · quests · save · menu. + Type 'help' anytime. Good luck. +``` + +## 4. `TutorialLoader` (loader) + +- `loader/dto/TutorialDto` (`intro`, `List steps`, `closingTips`), + `loader/dto/TutorialStepDto` (`instruction`, `expect`, `Integer minArgs`, `confirm`, `hint`). +- `TutorialLoader.load(basePath)` → `Tutorial`: liest `basePath + "/tutorial.yaml"` + optional (fehlt → `Tutorial.none()`), mappt DTO→Modell (`minArgs` null → 0). + Nutzt einen eigenen Jackson-`YAMLFactory`-Mapper wie `WorldLoader`. Default-Base + `/world` (überschreibbar für Tests). Parse-Fehler → `WorldLoadException`. +- Bewusst **getrennt von `World`**: Tutorial ist Onboarding, kein Welt-Inhalt. + +## 5. `TutorialGuide` (game) + +Zustandsbehaftet (ein laufendes Tutorial), hält die Schritte + den Index + die +`CommandRegistry` (für alias-fähiges Verb-Matching). + +```java +public final class TutorialGuide { + private final Tutorial tutorial; + private final CommandRegistry registry; + private int index = 0; + private boolean active; + private Room roomAtStepStart; // zum Erkennen echter Bewegung + + public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { + this.tutorial = tutorial; this.registry = registry; + this.active = !tutorial.isEmpty(); + } + public boolean isActive() { return active; } + + /** Druckt Intro + erste Anweisung (am Loop-Start, nach dem ersten look). */ + public void begin(GameContext ctx) { if (active) { print intro; enterStep(ctx); } } + + /** Nach jedem ausgeführten Befehl: prüft den aktuellen Schritt. */ + public void onCommand(GameContext ctx, ParsedCommand parsed) { + if (!active) return; + TutorialStep step = tutorial.steps().get(index); + if (satisfies(step, ctx, parsed)) { + ctx.getIo().write(step.confirm()); + index++; + if (index >= tutorial.steps().size()) { ctx.getIo().print(closingTips); active = false; } + else enterStep(ctx); + } else { + ctx.getIo().write(step.hint()); // gültiger-aber-falscher Befehl → Nudge + } + } + + /** 'skip' während des Tutorials: Abbruch ohne Zug. */ + public void skip(GameContext ctx) { if (active) { ctx.getIo().write("Tutorial skipped."); active = false; } } + + /** Merkt den aktuellen Raum (für Bewegungs-Erkennung) und druckt die Anweisung. */ + private void enterStep(GameContext ctx) { + roomAtStepStart = ctx.getPlayer().getCurrentRoom(); + ctx.getIo().write(tutorial.steps().get(index).instruction()); + } +} +``` + +**`satisfies(step, ctx, parsed)`**: +- `expect == "go_direction"` → `sameCommand(parsed.verb(), "go")` **und** `args` nicht leer + **und** erstes Arg ist eine Richtung (`Direction.fromString` wirft nicht) **und** + der Spieler ist **tatsächlich umgezogen** (`ctx.getPlayer().getCurrentRoom() != roomAtStepStart`). +- `expect == "go_to_room"` → `sameCommand(parsed.verb(), "go")` **und** `args` nicht leer + **und** erstes Arg ist **keine** Richtung **und** der Spieler ist tatsächlich umgezogen. +- sonst (Verb-Name) → `sameCommand(parsed.verb(), step.expect())` **und** + `args.size() >= step.minArgs()`. +- `sameCommand(a, b)` = `registry.find(a)` und `registry.find(b)` beide präsent und + **dieselbe** Command-Instanz (alias-fähig: `l`==`look`, `x`==`examine`, `get`==`take`, + `i`==`inventory`). + +Die Bewegungs-Bedingung verhindert, dass ein **blockierter** Zug (z.B. `go south` +in den dunklen Dungeon ohne Licht → kein Raumwechsel) den Schritt vorrückt — wichtig, +damit für `go_to_room` danach wirklich zwei Räume besucht sind. + +Hinweis: `onCommand` wird nur nach einem **erfolgreich aufgelösten** Befehl gerufen; +unbekannte Eingaben behandelt der Loop bereits („I don't understand …"). + +## 6. Game-Loop-Anbindung (game/Game.java) + +- Neues optionales Feld `private TutorialGuide tutorialGuide;` + `setTutorialGuide(...)` + (Zwei-Phasen-Wiring wie `QuitCommand`/`MenuCommand`). +- In `run()` **vor** der `while`-Schleife (nach dem initialen `publishHud`/`maybeEnd`): + `if (tutorialGuide != null) tutorialGuide.begin(ctx);` +- In der Schleife, **vor** dem Dispatch: wenn `tutorialGuide != null && + tutorialGuide.isActive() && parsed.verb().equals("skip")` → `tutorialGuide.skip(ctx); + publishHud(); continue;` (kein `turn++`). +- **Nach** `cmd.get().execute(...); turn++;`: + `if (tutorialGuide != null) tutorialGuide.onCommand(ctx, parsed);` + +Der Guide ist in normalem/geladenem Spiel `null` → kein Overhead, kein Verhalten. + +## 7. App-Anbindung (App.java) + +- `App.run` lädt das Tutorial einmal beim Start: `Tutorial tutorial = + new TutorialLoader().load();` (wie Settings/SaveService). +- `play(io, saves, session, boolean withTutorial)`: nach dem Registry-Aufbau, wenn + `withTutorial && !tutorial.isEmpty()` → `game.setTutorialGuide(new TutorialGuide( + tutorial, registry))`. +- `case NEW_GAME -> play(io, saves, newSession(io), true);` + `LOAD -> play(io, saves, saves.load(slot.slug()), false);` +- Reihenfolge in `play`: Banner → Willkommen → erstes `look` → `game.run()` (darin + `guide.begin`). Damit erscheint das Tutorial **nach** dem ersten Raumbild. + +## 8. Flow & Meldungen + +- Neues Spiel → Name → Banner/Willkommen → Raum-`look` → Intro + 1. Anweisung. +- Spieler tippt Befehle (echte Ausführung); passender Befehl → `confirm` + nächste + Anweisung; falscher (gültiger) → `hint`. Nach dem letzten Schritt → `closingTips`, + Guide inaktiv, normales Spiel läuft weiter (Spieler steht, wo das Tutorial endete). +- `skip` jederzeit → „Tutorial skipped." → normales Spiel. +- Während des Tutorials laufen HUD/Karte/Quests/Autosave normal (es ist der echte Loop). + +## 9. Tests + +- **`TutorialGuideTest`** (Fake-`CommandRegistry` mit echten Command-Instanzen oder + reale Registry; `TestIO`): + - Alias-Match: `l` erfüllt `look`-Schritt; `x` erfüllt `examine`. + - `minArgs`: `take` ohne Arg erfüllt **nicht** (minArgs 1), `take lamp` schon. + - `go_direction` (Arg = Richtung) vs `go_to_room` (Arg = keine Richtung). + - Falscher gültiger Befehl → `hint`, kein Vorrücken. + - Vorrücken + `confirm`; nach letztem Schritt → `closingTips` + `isActive()==false`. + - `skip` → inaktiv, „Tutorial skipped.". +- **`TutorialLoaderTest`**: lädt Fixture-`tutorial.yaml` → Intro/Steps/ClosingTips; + fehlende Datei → `Tutorial.none()` (`isEmpty()`). +- **`GameTutorialTest`** (Integration): kleine Welt + reale Registry + `TestIO` mit + skript-Eingaben durch `game.run()`; prüft, dass `begin` druckt, Schritte bei + passenden Befehlen vorrücken, und `skip` beendet. (App-/GUI-Wiring manuell.) +- Konvention: keine GUI-/YAML-abhängigen Domain-Tests außer dem Loader-Fixture. + +## 10. Architektur-Werte / Risiken + +- **Geteilter Loop bleibt zentral**: der Guide hängt sich minimal in `Game` ein; + Konsole und GUI verhalten sich identisch (normale styled Ausgabe, `skip` per Eingabe). +- **Datengetrieben**: Schritte/Texte in `tutorial.yaml`; nur das Matching ist Code. + Verb-Matching nutzt die Registry (alias-fähig, keine hartkodierten Alias-Listen). +- **Geringe Kopplung an Content**: Schritte sind verb-/richtungs-basiert (kein fixer + Item-Name nötig); ändert sich der Start-Content, bleibt das Tutorial gültig. + `go_to_room` funktioniert, weil nach dem `go`-Schritt zwei Räume besucht sind. +- **Echtes Spiel, kein Sandbox**: Tutorial-Aktionen sind reale Züge (Turn/Quests/ + Autosave laufen). Risiko: der Spieler „verbraucht" den ersten Raum — bewusst + akzeptiert (es ist die echte Welt; nichts geht verloren). +- **`skip`-Sonderfall**: nur während aktivem Tutorial abgefangen; außerhalb bleibt + `skip` ein unbekanntes Verb (harmlos). +- Risiko: `expect`-Tokens (`go_direction`/`go_to_room` + Verbnamen) sind ein kleines + bespoke Schema; dokumentiert in `tutorial.yaml`-Kommentar und im Loader. From 5c9a8cb568c6c1e72d9945288ee52ce22b50aefc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:23:06 +0200 Subject: [PATCH 2/9] docs: implementation plan for interactive start tutorial 4 TDD tasks: Tutorial model+loader+tutorial.yaml, TutorialGuide engine, Game+App wiring (New Game only), docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../superpowers/plans/2026-06-01-tutorial.md | 822 ++++++++++++++++++ 1 file changed, 822 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md diff --git a/Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md b/Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md new file mode 100644 index 0000000..bd8d10a --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md @@ -0,0 +1,822 @@ +# Interactive Start-of-Game Tutorial Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an interactive, skippable start-of-game walkthrough that makes the player perform the core commands (look→examine→take→inventory→use→go→go-to) in the real starting room, then lists the rest, shown only on New Game. + +**Architecture:** A stateful `TutorialGuide` (game package) is layered onto the real `Game` loop (optional; null in normal/loaded play). It prints an instruction, and after each executed command checks — via alias-aware registry lookup, with movement steps requiring an actual room change — whether the step is satisfied (confirm + advance) or not (gentle hint). Steps come from an optional `tutorial.yaml` loaded by a standalone `TutorialLoader` into a `Tutorial`/`TutorialStep` model. `skip` ends it. `App` wires the guide only for New Game. + +**Tech Stack:** Java 25, Maven, Jackson (YAML), JUnit 5 + AssertJ, Lombok. + +--- + +## File Structure + +**Create:** +- `model/Tutorial.java`, `model/TutorialStep.java` — domain records. +- `loader/dto/TutorialDto.java`, `loader/dto/TutorialStepDto.java` — YAML DTOs. +- `loader/TutorialLoader.java` — reads optional `/world/tutorial.yaml` → `Tutorial`. +- `game/TutorialGuide.java` — the guide. +- `src/main/resources/world/tutorial.yaml` — intro + 7 steps + closing tips. +- `src/test/resources/world/tutorial.yaml` — small 2-step loader fixture. + +**Modify:** +- `game/Game.java` — optional guide field + `setTutorialGuide` + loop hooks. +- `App.java` — `play(..., withTutorial)`, build guide on New Game. +- `docs/enhancement-ideas.md` — note the tutorial. + +--- + +## Task 1: Tutorial model, DTO, loader, content + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/model/Tutorial.java` +- Create: `src/main/java/thb/jeanluc/adventure/model/TutorialStep.java` +- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java` +- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java` +- Create: `src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java` +- Create: `src/main/resources/world/tutorial.yaml` +- Create: `src/test/resources/world/tutorial.yaml` +- Test: `src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java`: +```java +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.Tutorial; + +import static org.assertj.core.api.Assertions.assertThat; + +class TutorialLoaderTest { + + @Test + void loadsTheTestFixture() { + Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod + assertThat(t.isEmpty()).isFalse(); + assertThat(t.intro()).isNotBlank(); + assertThat(t.closingTips()).isNotBlank(); + assertThat(t.steps()).hasSize(2); + assertThat(t.steps().getFirst().expect()).isEqualTo("look"); + assertThat(t.steps().getFirst().minArgs()).isZero(); + } + + @Test + void missingFileReturnsEmptyTutorial() { + Tutorial t = new TutorialLoader("/no-such-base").load(); + assertThat(t.isEmpty()).isTrue(); + assertThat(t.steps()).isEmpty(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=TutorialLoaderTest` +Expected: FAIL — `Tutorial`/`TutorialLoader` do not exist. + +- [ ] **Step 3: Create the model records** + +`src/main/java/thb/jeanluc/adventure/model/TutorialStep.java`: +```java +package thb.jeanluc.adventure.model; + +/** + * One interactive tutorial step: an instruction, the command that satisfies it + * ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"), + * a minimum argument count, a success confirmation, and a hint for a valid but + * wrong command. + */ +public record TutorialStep( + String instruction, + String expect, + int minArgs, + String confirm, + String hint +) { +} +``` + +`src/main/java/thb/jeanluc/adventure/model/Tutorial.java`: +```java +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */ +public record Tutorial(String intro, List steps, String closingTips) { + + public Tutorial { + steps = steps == null ? List.of() : List.copyOf(steps); + } + + public boolean isEmpty() { + return steps.isEmpty(); + } + + /** An absent tutorial (no steps). */ + public static Tutorial none() { + return new Tutorial(null, List.of(), null); + } +} +``` + +- [ ] **Step 4: Create the DTOs** + +`src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +/** YAML representation of a tutorial step. */ +public record TutorialStepDto( + String instruction, + String expect, + Integer minArgs, + String confirm, + String hint +) { +} +``` + +`src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of the tutorial document. */ +public record TutorialDto(String intro, List steps, String closingTips) { +} +``` + +- [ ] **Step 5: Create `TutorialLoader`** + +`src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java`: +```java +package thb.jeanluc.adventure.loader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import thb.jeanluc.adventure.loader.dto.TutorialDto; +import thb.jeanluc.adventure.loader.dto.TutorialStepDto; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads the optional onboarding tutorial from {@code /tutorial.yaml}. + * Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world + * content. Returns {@link Tutorial#none()} when the file is absent. + */ +public class TutorialLoader { + + /** Default classpath base directory. */ + public static final String DEFAULT_BASE = "/world"; + + private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); + private final String basePath; + + public TutorialLoader() { + this(DEFAULT_BASE); + } + + public TutorialLoader(String basePath) { + this.basePath = basePath; + } + + /** Reads the tutorial; absent file → {@link Tutorial#none()}. */ + public Tutorial load() { + String resource = basePath + "/tutorial.yaml"; + try (InputStream in = getClass().getResourceAsStream(resource)) { + if (in == null) { + return Tutorial.none(); + } + TutorialDto dto = yaml.readValue(in, TutorialDto.class); + if (dto == null) { + return Tutorial.none(); + } + List steps = new ArrayList<>(); + if (dto.steps() != null) { + for (TutorialStepDto s : dto.steps()) { + steps.add(new TutorialStep( + s.instruction(), + s.expect(), + s.minArgs() == null ? 0 : s.minArgs(), + s.confirm(), + s.hint())); + } + } + return new Tutorial(dto.intro(), steps, dto.closingTips()); + } catch (IOException e) { + throw new WorldLoadException("Failed to parse " + resource, e); + } + } +} +``` + +- [ ] **Step 6: Create the production `tutorial.yaml`** + +`src/main/resources/world/tutorial.yaml`: +```yaml +intro: | + Welcome to the manor. Let me show you the ropes — just type the commands as + I describe them. (Type 'skip' at any time to jump straight into the game.) +steps: + - instruction: "First, look around the room: type 'look'." + expect: look + confirm: "Good — 'look' (or 'l') re-describes wherever you are." + hint: "When you're ready, just type: look" + - instruction: "Examine something you noticed: 'examine ' (or 'x ')." + expect: examine + minArgs: 1 + confirm: "That's how you inspect items, exits, and people up close." + hint: "Try examining something, e.g.: examine lamp" + - instruction: "Pick something up: 'take '." + expect: take + minArgs: 1 + confirm: "Taken. Things you carry go into your inventory." + hint: "Pick something up, e.g.: take lamp" + - instruction: "Check what you're carrying: 'inventory' (or 'i')." + expect: inventory + confirm: "That's your inventory." + hint: "Type: inventory" + - instruction: "Use an item: 'use ' — try lighting the lamp." + expect: use + minArgs: 1 + confirm: "Some items toggle, read, or react when you use them." + hint: "Use something, e.g.: use lamp" + - instruction: "Now move to another room: 'go ' (try 'go north')." + expect: go_direction + confirm: "You can move north / south / east / west between connected rooms." + hint: "Head to a connected room, e.g.: go north" + - instruction: "Travel back to a room you've already seen: 'go to '." + expect: go_to_room + confirm: "Nice — 'go to ' auto-walks to any room you've already visited." + hint: "Try: go to " +closingTips: | + That's the core. A few more you'll want: + read · drop · talk to · give to · + use on (combine things) · map · quests · save · menu. + Type 'help' anytime. Good luck. +``` + +- [ ] **Step 7: Create the test fixture `tutorial.yaml`** + +`src/test/resources/world/tutorial.yaml` (small, deterministic for the loader test): +```yaml +intro: | + Test intro. +steps: + - instruction: "Look: type 'look'." + expect: look + confirm: "Looked." + hint: "Type look." + - instruction: "Take: type 'take '." + expect: take + minArgs: 1 + confirm: "Took it." + hint: "Take something." +closingTips: | + Test tips. +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=TutorialLoaderTest` +Expected: PASS (2 tests). + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/Tutorial.java \ + src/main/java/thb/jeanluc/adventure/model/TutorialStep.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java \ + src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java \ + src/main/resources/world/tutorial.yaml \ + src/test/resources/world/tutorial.yaml \ + src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java +git commit -m "feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content" +``` + +--- + +## Task 2: `TutorialGuide` + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.command.impl.ExamineCommand; +import thb.jeanluc.adventure.command.impl.GoCommand; +import thb.jeanluc.adventure.command.impl.InventoryCommand; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.command.impl.TakeCommand; +import thb.jeanluc.adventure.command.impl.UseCommand; +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.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; +import thb.jeanluc.adventure.model.World; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TutorialGuideTest { + + private CommandRegistry registry() { + CommandRegistry r = new CommandRegistry(); + r.register(new LookCommand(), "look", "l"); + r.register(new ExamineCommand(), "examine", "x"); + r.register(new TakeCommand(), "take", "get"); + r.register(new InventoryCommand(), "inventory", "i"); + r.register(new UseCommand(), "use"); + r.register(new GoCommand(), "go", "move", "walk"); + return r; + } + + private final Room kitchen = new Room("kitchen", "Kitchen", "d"); + private final Room hallway = new Room("hallway", "Hallway", "d"); + + private GameContext ctx() { + kitchen.addExit(Direction.NORTH, hallway); + Map rooms = new LinkedHashMap<>(); + rooms.put("kitchen", kitchen); + rooms.put("hallway", hallway); + World w = new World(rooms, Map.of(), Map.of(), "t", "w"); + Player p = new Player(kitchen, 0); + return new GameContext(w, p, new TestIO()); + } + + private ParsedCommand parse(String s) { + return new CommandParser().parse(s); + } + + private String out(GameContext ctx) { + return ((TestIO) ctx.getIo()).allOutput(); + } + + private TutorialStep step(String instr, String expect, int minArgs) { + return new TutorialStep(instr, expect, minArgs, "OK:" + expect, "HINT:" + expect); + } + + @Test + void beginPrintsIntroAndFirstInstruction() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + assertThat(out(ctx)).contains("INTRO").contains("do look"); + assertThat(g.isActive()).isTrue(); + } + + @Test + void aliasSatisfiesVerbStepAndAdvancesToTips() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("l")); // alias of look + assertThat(out(ctx)).contains("OK:look").contains("TIPS"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void minArgsGatesTakeStep() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("take it", "take", 1)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("take")); // no arg -> hint, not satisfied + assertThat(out(ctx)).contains("HINT:take"); + assertThat(g.isActive()).isTrue(); + g.onCommand(ctx, parse("take lamp")); // satisfied + assertThat(out(ctx)).contains("OK:take"); + } + + @Test + void goDirectionRequiresAnActualMove() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("move", "go_direction", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + // typed a direction but did NOT move -> hint + g.onCommand(ctx, parse("go north")); + assertThat(out(ctx)).contains("HINT:go_direction"); + assertThat(g.isActive()).isTrue(); + // now actually move, then the same command satisfies + ctx.getPlayer().setCurrentRoom(hallway); + g.onCommand(ctx, parse("go north")); + assertThat(out(ctx)).contains("OK:go_direction"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void goToRoomNeedsNonDirectionArgAndMove() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + ctx.getPlayer().setCurrentRoom(hallway); // moved + g.onCommand(ctx, parse("go kitchen")); // "kitchen" is not a direction + assertThat(out(ctx)).contains("OK:go_to_room"); + } + + @Test + void skipDeactivates() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.skip(ctx); + assertThat(out(ctx)).contains("Tutorial skipped"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void emptyTutorialIsInactive() { + TutorialGuide g = new TutorialGuide(Tutorial.none(), registry()); + assertThat(g.isActive()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=TutorialGuideTest` +Expected: FAIL — `TutorialGuide` does not exist. + +- [ ] **Step 3: Create `TutorialGuide`** + +`src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java`: +```java +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; + +import java.util.Optional; + +/** + * Drives the interactive start-of-game walkthrough, layered onto the real game + * loop. Prints an instruction, then on each executed command decides whether the + * current step is satisfied (confirm + advance) or not (hint). Movement steps + * require an actual room change. Alias-aware via the {@link CommandRegistry}. + */ +public final class TutorialGuide { + + private final Tutorial tutorial; + private final CommandRegistry registry; + private int index; + private boolean active; + private Room roomAtStepStart; + + public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { + this.tutorial = tutorial; + this.registry = registry; + this.active = !tutorial.isEmpty(); + } + + public boolean isActive() { + return active; + } + + /** Prints the intro and the first instruction (call once at loop start). */ + public void begin(GameContext ctx) { + if (!active) { + return; + } + if (tutorial.intro() != null && !tutorial.intro().isBlank()) { + ctx.getIo().write(tutorial.intro()); + } + enterStep(ctx); + } + + /** Evaluates the current step against the command that was just executed. */ + public void onCommand(GameContext ctx, ParsedCommand parsed) { + if (!active) { + return; + } + TutorialStep step = tutorial.steps().get(index); + if (satisfies(step, ctx, parsed)) { + ctx.getIo().write(step.confirm()); + index++; + if (index >= tutorial.steps().size()) { + if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) { + ctx.getIo().write(tutorial.closingTips()); + } + active = false; + } else { + enterStep(ctx); + } + } else { + ctx.getIo().write(step.hint()); + } + } + + /** Ends the tutorial early (the 'skip' command). */ + public void skip(GameContext ctx) { + if (active) { + ctx.getIo().write("Tutorial skipped."); + active = false; + } + } + + private void enterStep(GameContext ctx) { + roomAtStepStart = ctx.getPlayer().getCurrentRoom(); + ctx.getIo().write(tutorial.steps().get(index).instruction()); + } + + private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) { + String expect = step.expect(); + if ("go_direction".equals(expect)) { + return isGo(parsed) && firstIsDirection(parsed) && moved(ctx); + } + if ("go_to_room".equals(expect)) { + return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx); + } + return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs(); + } + + private boolean isGo(ParsedCommand p) { + return sameCommand(p.verb(), "go") && !p.args().isEmpty(); + } + + private boolean firstIsDirection(ParsedCommand p) { + if (p.args().isEmpty()) { + return false; + } + try { + Direction.fromString(p.args().get(0)); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + private boolean moved(GameContext ctx) { + return ctx.getPlayer().getCurrentRoom() != roomAtStepStart; + } + + private boolean sameCommand(String a, String b) { + Optional ca = registry.find(a); + Optional cb = registry.find(b); + return ca.isPresent() && cb.isPresent() && ca.get() == cb.get(); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=TutorialGuideTest` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java \ + src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java +git commit -m "feat(game): TutorialGuide interactive walkthrough engine" +``` + +--- + +## Task 3: Wire the guide into `Game` and `App` + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java` +- Modify: `src/main/java/thb/jeanluc/adventure/App.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; +import thb.jeanluc.adventure.model.World; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameTutorialTest { + + @Test + void guideBeginsAndAdvancesThenSkipEndsViaLoop() { + Room kitchen = new Room("kitchen", "Kitchen", "d"); + World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(), "t", "w"); + TestIO io = new TestIO(); + // scripted player input: a matching 'look', then 'skip'; then EOF (null) ends run() + io.enqueue("look").enqueue("skip"); + GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io); + + CommandRegistry registry = new CommandRegistry(); + registry.register(new LookCommand(), "look", "l"); + + Tutorial t = new Tutorial("INTRO", + List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint"), + new TutorialStep("do look again", "look", 0, "STEP2-OK", "hint")), + "TIPS"); + Game game = new Game(ctx, registry, new CommandParser()); + game.setTutorialGuide(new TutorialGuide(t, registry)); + + game.run(); + + String out = io.allOutput(); + assertThat(out).contains("INTRO"); // begin printed + assertThat(out).contains("STEP1-OK"); // first 'look' advanced step 1 + assertThat(out).contains("Tutorial skipped"); // 'skip' ended it before step 2 finished + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=GameTutorialTest` +Expected: FAIL — `Game.setTutorialGuide` does not exist. + +- [ ] **Step 3: Add the guide hooks to `Game`** + +In `src/main/java/thb/jeanluc/adventure/game/Game.java`: + +1. Add the import: +```java +import thb.jeanluc.adventure.game.TutorialGuide; +``` +(Same package — skip the import; reference `TutorialGuide` directly.) + +2. Add a field after `private boolean running = true;`: +```java + /** Optional onboarding guide; null in normal/loaded play. */ + private TutorialGuide tutorialGuide; + + /** Wires the interactive tutorial (New Game only). */ + public void setTutorialGuide(TutorialGuide tutorialGuide) { + this.tutorialGuide = tutorialGuide; + } +``` + +3. In `run()`, after the initial `publishHud(); maybeEnd();` and before `while (running) {`: +```java + if (tutorialGuide != null) { + tutorialGuide.begin(ctx); + } +``` + +4. Inside the loop, replace the body from the `parsed.verb().isEmpty()` check through the dispatch with this (adds the skip interception and the post-command hook): +```java + ParsedCommand parsed = parser.parse(input); + if (parsed.verb().isEmpty()) { + continue; + } + if (tutorialGuide != null && tutorialGuide.isActive() + && parsed.verb().equals("skip")) { + tutorialGuide.skip(ctx); + publishHud(); + continue; + } + Optional cmd = registry.find(parsed.verb()); + if (cmd.isEmpty()) { + ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); + } else { + cmd.get().execute(ctx, parsed.args()); + ctx.getSession().incrementTurn(); + if (ctx.getSession().getTurn() % 10 == 0) { + ctx.save(); + } + if (tutorialGuide != null) { + tutorialGuide.onCommand(ctx, parsed); + } + } + publishHud(); + maybeEnd(); +``` + +- [ ] **Step 4: Wire `App`** + +In `src/main/java/thb/jeanluc/adventure/App.java`: + +1. Add imports: +```java +import thb.jeanluc.adventure.game.TutorialGuide; +import thb.jeanluc.adventure.loader.TutorialLoader; +import thb.jeanluc.adventure.model.Tutorial; +``` + +2. Change the two `play(...)` call sites in `run()`: +```java + case NEW_GAME -> play(io, saves, newSession(io), true); +``` +and inside the LOAD branch: +```java + play(io, saves, saves.load(slot.slug()), false); +``` + +3. Change the `play` signature and add the guide wiring (after the `HelpCommand` is registered, before the banner): +```java + private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) { +``` +and, immediately after `registry.register(new HelpCommand(registry), "help", "?");`: +```java + if (withTutorial) { + Tutorial tutorial = new TutorialLoader().load(); + if (!tutorial.isEmpty()) { + game.setTutorialGuide(new TutorialGuide(tutorial, registry)); + } + } +``` + +- [ ] **Step 5: Run tests + full suite** + +Run: `mvn -q test -Dtest=GameTutorialTest` +Expected: PASS. +Run: `mvn -q clean test` +Expected: BUILD SUCCESS, all green (existing GameTest / loop tests unaffected — guide is null there). + +- [ ] **Step 6: Manual console smoke (optional)** + +Run: `mvn -q -DskipTests compile dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt >/dev/null 2>&1` then +`printf '1\nHero\nlook\nskip\nquit\n4\n' | java -cp "target/classes:$(cat /tmp/cp.txt)" thb.jeanluc.adventure.App` +Expected: New Game → name → room shown → tutorial intro + "look" instruction → after `look`, confirmation + next instruction → `skip` → "Tutorial skipped." → normal play → `quit` → menu → Quit. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/Game.java \ + src/main/java/thb/jeanluc/adventure/App.java \ + src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java +git commit -m "feat: wire TutorialGuide into the game loop; show on New Game only" +``` + +--- + +## Task 4: Docs + +**Files:** +- Modify: `docs/enhancement-ideas.md` + +- [ ] **Step 1: Add a tutorial note** + +In `docs/enhancement-ideas.md`, under the "Festgelegte Erweiterungs-Entscheidungen" +area (near the pathfinding ✅ note), add: + +> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough +> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte +> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel), Abschluss- +> Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`, +> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem +> Spiel, nicht beim Laden. + +- [ ] **Step 2: Commit** + +```bash +git add docs/enhancement-ideas.md +git commit -m "docs: note interactive start-of-game tutorial" +``` + +--- + +## Final Verification + +- [ ] `mvn -q clean test` — all tests pass (197 prior + TutorialLoader/TutorialGuide/GameTutorial suites). +- [ ] Manual console smoke (Task 3 Step 6) behaves as described. +- [ ] Manual: Load a saved game → NO tutorial appears. +- [ ] `git status` clean. +``` From 95098fbb09f5d37bab2e1a3a89cb92ff90384644 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:25:48 +0200 Subject: [PATCH 3/9] feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content Co-Authored-By: Claude Sonnet 4.6 --- .../adventure/loader/TutorialLoader.java | 63 +++++++++++++++++++ .../adventure/loader/dto/TutorialDto.java | 7 +++ .../adventure/loader/dto/TutorialStepDto.java | 11 ++++ .../thb/jeanluc/adventure/model/Tutorial.java | 20 ++++++ .../jeanluc/adventure/model/TutorialStep.java | 16 +++++ .../src/main/resources/world/tutorial.yaml | 40 ++++++++++++ .../adventure/loader/TutorialLoaderTest.java | 27 ++++++++ .../src/test/resources/world/tutorial.yaml | 14 +++++ 8 files changed, 198 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java create mode 100644 Semesterprojekt/src/main/resources/world/tutorial.yaml create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java create mode 100644 Semesterprojekt/src/test/resources/world/tutorial.yaml diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java new file mode 100644 index 0000000..7ebabcb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java @@ -0,0 +1,63 @@ +package thb.jeanluc.adventure.loader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import thb.jeanluc.adventure.loader.dto.TutorialDto; +import thb.jeanluc.adventure.loader.dto.TutorialStepDto; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads the optional onboarding tutorial from {@code /tutorial.yaml}. + * Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world + * content. Returns {@link Tutorial#none()} when the file is absent. + */ +public class TutorialLoader { + + /** Default classpath base directory. */ + public static final String DEFAULT_BASE = "/world"; + + private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); + private final String basePath; + + public TutorialLoader() { + this(DEFAULT_BASE); + } + + public TutorialLoader(String basePath) { + this.basePath = basePath; + } + + /** Reads the tutorial; absent file → {@link Tutorial#none()}. */ + public Tutorial load() { + String resource = basePath + "/tutorial.yaml"; + try (InputStream in = getClass().getResourceAsStream(resource)) { + if (in == null) { + return Tutorial.none(); + } + TutorialDto dto = yaml.readValue(in, TutorialDto.class); + if (dto == null) { + return Tutorial.none(); + } + List steps = new ArrayList<>(); + if (dto.steps() != null) { + for (TutorialStepDto s : dto.steps()) { + steps.add(new TutorialStep( + s.instruction(), + s.expect(), + s.minArgs() == null ? 0 : s.minArgs(), + s.confirm(), + s.hint())); + } + } + return new Tutorial(dto.intro(), steps, dto.closingTips()); + } catch (IOException e) { + throw new WorldLoadException("Failed to parse " + resource, e); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java new file mode 100644 index 0000000..ab113eb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java @@ -0,0 +1,7 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of the tutorial document. */ +public record TutorialDto(String intro, List steps, String closingTips) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java new file mode 100644 index 0000000..e3f622b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java @@ -0,0 +1,11 @@ +package thb.jeanluc.adventure.loader.dto; + +/** YAML representation of a tutorial step. */ +public record TutorialStepDto( + String instruction, + String expect, + Integer minArgs, + String confirm, + String hint +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java new file mode 100644 index 0000000..338b2e2 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java @@ -0,0 +1,20 @@ +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */ +public record Tutorial(String intro, List steps, String closingTips) { + + public Tutorial { + steps = steps == null ? List.of() : List.copyOf(steps); + } + + public boolean isEmpty() { + return steps.isEmpty(); + } + + /** An absent tutorial (no steps). */ + public static Tutorial none() { + return new Tutorial(null, List.of(), null); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java new file mode 100644 index 0000000..e32b587 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java @@ -0,0 +1,16 @@ +package thb.jeanluc.adventure.model; + +/** + * One interactive tutorial step: an instruction, the command that satisfies it + * ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"), + * a minimum argument count, a success confirmation, and a hint for a valid but + * wrong command. + */ +public record TutorialStep( + String instruction, + String expect, + int minArgs, + String confirm, + String hint +) { +} diff --git a/Semesterprojekt/src/main/resources/world/tutorial.yaml b/Semesterprojekt/src/main/resources/world/tutorial.yaml new file mode 100644 index 0000000..1dd7573 --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/tutorial.yaml @@ -0,0 +1,40 @@ +intro: | + Welcome to the manor. Let me show you the ropes — just type the commands as + I describe them. (Type 'skip' at any time to jump straight into the game.) +steps: + - instruction: "First, look around the room: type 'look'." + expect: look + confirm: "Good — 'look' (or 'l') re-describes wherever you are." + hint: "When you're ready, just type: look" + - instruction: "Examine something you noticed: 'examine ' (or 'x ')." + expect: examine + minArgs: 1 + confirm: "That's how you inspect items, exits, and people up close." + hint: "Try examining something, e.g.: examine lamp" + - instruction: "Pick something up: 'take '." + expect: take + minArgs: 1 + confirm: "Taken. Things you carry go into your inventory." + hint: "Pick something up, e.g.: take lamp" + - instruction: "Check what you're carrying: 'inventory' (or 'i')." + expect: inventory + confirm: "That's your inventory." + hint: "Type: inventory" + - instruction: "Use an item: 'use ' — try lighting the lamp." + expect: use + minArgs: 1 + confirm: "Some items toggle, read, or react when you use them." + hint: "Use something, e.g.: use lamp" + - instruction: "Now move to another room: 'go ' (try 'go north')." + expect: go_direction + confirm: "You can move north / south / east / west between connected rooms." + hint: "Head to a connected room, e.g.: go north" + - instruction: "Travel back to a room you've already seen: 'go to '." + expect: go_to_room + confirm: "Nice — 'go to ' auto-walks to any room you've already visited." + hint: "Try: go to " +closingTips: | + That's the core. A few more you'll want: + read · drop · talk to · give to · + use on (combine things) · map · quests · save · menu. + Type 'help' anytime. Good luck. diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java new file mode 100644 index 0000000..937306b --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.Tutorial; + +import static org.assertj.core.api.Assertions.assertThat; + +class TutorialLoaderTest { + + @Test + void loadsTheTestFixture() { + Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod + assertThat(t.isEmpty()).isFalse(); + assertThat(t.intro()).isNotBlank(); + assertThat(t.closingTips()).isNotBlank(); + assertThat(t.steps()).hasSize(2); + assertThat(t.steps().getFirst().expect()).isEqualTo("look"); + assertThat(t.steps().getFirst().minArgs()).isZero(); + } + + @Test + void missingFileReturnsEmptyTutorial() { + Tutorial t = new TutorialLoader("/no-such-base").load(); + assertThat(t.isEmpty()).isTrue(); + assertThat(t.steps()).isEmpty(); + } +} diff --git a/Semesterprojekt/src/test/resources/world/tutorial.yaml b/Semesterprojekt/src/test/resources/world/tutorial.yaml new file mode 100644 index 0000000..950bf44 --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/tutorial.yaml @@ -0,0 +1,14 @@ +intro: | + Test intro. +steps: + - instruction: "Look: type 'look'." + expect: look + confirm: "Looked." + hint: "Type look." + - instruction: "Take: type 'take '." + expect: take + minArgs: 1 + confirm: "Took it." + hint: "Take something." +closingTips: | + Test tips. From f47a3e7ca5e07a3c9b5fc97a1b4791b5545dd2dd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:40:06 +0200 Subject: [PATCH 4/9] test(tutorial): cover malformed tutorial.yaml -> WorldLoadException --- .../thb/jeanluc/adventure/loader/TutorialLoaderTest.java | 8 ++++++++ Semesterprojekt/src/test/resources/badtut/tutorial.yaml | 5 +++++ 2 files changed, 13 insertions(+) create mode 100644 Semesterprojekt/src/test/resources/badtut/tutorial.yaml diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java index 937306b..a167f6e 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.model.Tutorial; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class TutorialLoaderTest { @@ -24,4 +25,11 @@ class TutorialLoaderTest { assertThat(t.isEmpty()).isTrue(); assertThat(t.steps()).isEmpty(); } + + @Test + void malformedFileThrowsWorldLoadException() { + // /badtut/tutorial.yaml has 'steps: 12345' (scalar where a list is required) + assertThatThrownBy(() -> new TutorialLoader("/badtut").load()) + .isInstanceOf(WorldLoadException.class); + } } diff --git a/Semesterprojekt/src/test/resources/badtut/tutorial.yaml b/Semesterprojekt/src/test/resources/badtut/tutorial.yaml new file mode 100644 index 0000000..97dcc56 --- /dev/null +++ b/Semesterprojekt/src/test/resources/badtut/tutorial.yaml @@ -0,0 +1,5 @@ +# Malformed on purpose: 'steps' must be a list, not a scalar. +# Used by TutorialLoaderTest to verify parse errors become WorldLoadException. +intro: "bad" +steps: 12345 +closingTips: "bad" From 546a7672bfd9f7c606371a34a482decb33e0c571 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:42:30 +0200 Subject: [PATCH 5/9] feat(game): TutorialGuide interactive walkthrough engine Alias-aware, stateful engine with verb/minArgs matching, go_direction/go_to_room room-change detection, skip, and closing-tips on completion. 7 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanluc/adventure/game/TutorialGuide.java | 119 +++++++++++++++ .../adventure/game/TutorialGuideTest.java | 143 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java new file mode 100644 index 0000000..2ac91aa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java @@ -0,0 +1,119 @@ +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; + +import java.util.Optional; + +/** + * Drives the interactive start-of-game walkthrough, layered onto the real game + * loop. Prints an instruction, then on each executed command decides whether the + * current step is satisfied (confirm + advance) or not (hint). Movement steps + * require an actual room change. Alias-aware via the {@link CommandRegistry}. + */ +public final class TutorialGuide { + + private final Tutorial tutorial; + private final CommandRegistry registry; + private int index; + private boolean active; + private Room roomAtStepStart; + + public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { + this.tutorial = tutorial; + this.registry = registry; + this.active = !tutorial.isEmpty(); + } + + public boolean isActive() { + return active; + } + + /** Prints the intro and the first instruction (call once at loop start). */ + public void begin(GameContext ctx) { + if (!active) { + return; + } + if (tutorial.intro() != null && !tutorial.intro().isBlank()) { + ctx.getIo().write(tutorial.intro()); + } + enterStep(ctx); + } + + /** Evaluates the current step against the command that was just executed. */ + public void onCommand(GameContext ctx, ParsedCommand parsed) { + if (!active) { + return; + } + TutorialStep step = tutorial.steps().get(index); + if (satisfies(step, ctx, parsed)) { + ctx.getIo().write(step.confirm()); + index++; + if (index >= tutorial.steps().size()) { + if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) { + ctx.getIo().write(tutorial.closingTips()); + } + active = false; + } else { + enterStep(ctx); + } + } else { + ctx.getIo().write(step.hint()); + } + } + + /** Ends the tutorial early (the 'skip' command). */ + public void skip(GameContext ctx) { + if (active) { + ctx.getIo().write("Tutorial skipped."); + active = false; + } + } + + private void enterStep(GameContext ctx) { + roomAtStepStart = ctx.getPlayer().getCurrentRoom(); + ctx.getIo().write(tutorial.steps().get(index).instruction()); + } + + private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) { + String expect = step.expect(); + if ("go_direction".equals(expect)) { + return isGo(parsed) && firstIsDirection(parsed) && moved(ctx); + } + if ("go_to_room".equals(expect)) { + return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx); + } + return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs(); + } + + private boolean isGo(ParsedCommand p) { + return sameCommand(p.verb(), "go") && !p.args().isEmpty(); + } + + private boolean firstIsDirection(ParsedCommand p) { + if (p.args().isEmpty()) { + return false; + } + try { + Direction.fromString(p.args().get(0)); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + private boolean moved(GameContext ctx) { + return ctx.getPlayer().getCurrentRoom() != roomAtStepStart; + } + + private boolean sameCommand(String a, String b) { + Optional ca = registry.find(a); + Optional cb = registry.find(b); + return ca.isPresent() && cb.isPresent() && ca.get() == cb.get(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java new file mode 100644 index 0000000..84b06f5 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java @@ -0,0 +1,143 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.command.impl.ExamineCommand; +import thb.jeanluc.adventure.command.impl.GoCommand; +import thb.jeanluc.adventure.command.impl.InventoryCommand; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.command.impl.TakeCommand; +import thb.jeanluc.adventure.command.impl.UseCommand; +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.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; +import thb.jeanluc.adventure.model.World; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TutorialGuideTest { + + private CommandRegistry registry() { + CommandRegistry r = new CommandRegistry(); + r.register(new LookCommand(), "look", "l"); + r.register(new ExamineCommand(), "examine", "x"); + r.register(new TakeCommand(), "take", "get"); + r.register(new InventoryCommand(), "inventory", "i"); + r.register(new UseCommand(), "use"); + r.register(new GoCommand(), "go", "move", "walk"); + return r; + } + + private final Room kitchen = new Room("kitchen", "Kitchen", "d"); + private final Room hallway = new Room("hallway", "Hallway", "d"); + + private GameContext ctx() { + kitchen.addExit(Direction.NORTH, hallway); + Map rooms = new LinkedHashMap<>(); + rooms.put("kitchen", kitchen); + rooms.put("hallway", hallway); + World w = new World(rooms, Map.of(), Map.of(), "t", "w"); + Player p = new Player(kitchen, 0); + return new GameContext(w, p, new TestIO()); + } + + private ParsedCommand parse(String s) { + return new CommandParser().parse(s); + } + + private String out(GameContext ctx) { + return ((TestIO) ctx.getIo()).allOutput(); + } + + private TutorialStep step(String instr, String expect, int minArgs) { + return new TutorialStep(instr, expect, minArgs, "OK:" + expect, "HINT:" + expect); + } + + @Test + void beginPrintsIntroAndFirstInstruction() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + assertThat(out(ctx)).contains("INTRO").contains("do look"); + assertThat(g.isActive()).isTrue(); + } + + @Test + void aliasSatisfiesVerbStepAndAdvancesToTips() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("l")); // alias of look + assertThat(out(ctx)).contains("OK:look").contains("TIPS"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void minArgsGatesTakeStep() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("take it", "take", 1)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("take")); // no arg -> hint, not satisfied + assertThat(out(ctx)).contains("HINT:take"); + assertThat(g.isActive()).isTrue(); + g.onCommand(ctx, parse("take lamp")); // satisfied + assertThat(out(ctx)).contains("OK:take"); + } + + @Test + void goDirectionRequiresAnActualMove() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("move", "go_direction", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + // typed a direction but did NOT move -> hint + g.onCommand(ctx, parse("go north")); + assertThat(out(ctx)).contains("HINT:go_direction"); + assertThat(g.isActive()).isTrue(); + // now actually move, then the same command satisfies + ctx.getPlayer().setCurrentRoom(hallway); + g.onCommand(ctx, parse("go north")); + assertThat(out(ctx)).contains("OK:go_direction"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void goToRoomNeedsNonDirectionArgAndMove() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + ctx.getPlayer().setCurrentRoom(hallway); // moved + g.onCommand(ctx, parse("go kitchen")); // "kitchen" is not a direction + assertThat(out(ctx)).contains("OK:go_to_room"); + } + + @Test + void skipDeactivates() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.skip(ctx); + assertThat(out(ctx)).contains("Tutorial skipped"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void emptyTutorialIsInactive() { + TutorialGuide g = new TutorialGuide(Tutorial.none(), registry()); + assertThat(g.isActive()).isFalse(); + } +} From 28871b4ddc9af45cb92fcc1ac0a6498b36fc5421 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:49:47 +0200 Subject: [PATCH 6/9] fix(game): null-safe moved() + idempotent begin(); cover multi-step + go_to_room hint Co-Authored-By: Claude Sonnet 4.6 --- .../jeanluc/adventure/game/TutorialGuide.java | 8 +++++-- .../adventure/game/TutorialGuideTest.java | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java index 2ac91aa..a96a2f7 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java @@ -22,6 +22,7 @@ public final class TutorialGuide { private final CommandRegistry registry; private int index; private boolean active; + private boolean begun; private Room roomAtStepStart; public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { @@ -30,15 +31,17 @@ public final class TutorialGuide { this.active = !tutorial.isEmpty(); } + /** @return true while the walkthrough is still running. */ public boolean isActive() { return active; } /** Prints the intro and the first instruction (call once at loop start). */ public void begin(GameContext ctx) { - if (!active) { + if (!active || begun) { return; } + begun = true; if (tutorial.intro() != null && !tutorial.intro().isBlank()) { ctx.getIo().write(tutorial.intro()); } @@ -108,7 +111,8 @@ public final class TutorialGuide { } private boolean moved(GameContext ctx) { - return ctx.getPlayer().getCurrentRoom() != roomAtStepStart; + return roomAtStepStart != null + && ctx.getPlayer().getCurrentRoom() != roomAtStepStart; } private boolean sameCommand(String a, String b) { diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java index 84b06f5..529f0e7 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java @@ -140,4 +140,27 @@ class TutorialGuideTest { TutorialGuide g = new TutorialGuide(Tutorial.none(), registry()); assertThat(g.isActive()).isFalse(); } + + @Test + void advancingPrintsTheNextInstruction() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", + List.of(step("do look", "look", 0), step("do take", "take", 1)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("look")); + assertThat(out(ctx)).contains("OK:look").contains("do take"); + assertThat(g.isActive()).isTrue(); + } + + @Test + void goToRoomWithoutMovingGivesHint() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("go kitchen")); // non-direction arg but no room change + assertThat(out(ctx)).contains("HINT:go_to_room"); + assertThat(g.isActive()).isTrue(); + } } From 2ed6a71cea34229c3d9d099f32e96baa64d35daf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:53:27 +0200 Subject: [PATCH 7/9] feat: wire TutorialGuide into the game loop; show on New Game only Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/thb/jeanluc/adventure/App.java | 16 +++++-- .../java/thb/jeanluc/adventure/game/Game.java | 20 ++++++++ .../adventure/game/GameTutorialTest.java | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index 8444308..ff6367d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -21,6 +21,9 @@ 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.game.TutorialGuide; +import thb.jeanluc.adventure.loader.TutorialLoader; +import thb.jeanluc.adventure.model.Tutorial; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; @@ -70,12 +73,12 @@ public final class App { boolean running = true; while (running) { switch (MainMenu.show(io)) { - case NEW_GAME -> play(io, saves, newSession(io)); + case NEW_GAME -> play(io, saves, newSession(io), true); case LOAD -> { SaveSlotInfo slot = MainMenu.pickSlot(io, saves); if (slot != null) { try { - play(io, saves, saves.load(slot.slug())); + play(io, saves, saves.load(slot.slug()), false); } catch (SaveException e) { io.write("Could not load: " + e.getUserMessage()); } @@ -96,7 +99,7 @@ public final class App { } /** Builds the registry, runs one game to completion, autosaving to its slot. */ - private static void play(GameIO io, SaveService saves, GameSession session) { + private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) { GameContext ctx = new GameContext(session, io); ctx.setSaveCallback(() -> { try { @@ -128,6 +131,13 @@ public final class App { registry.register(menu, "menu", "quit", "exit"); registry.register(new HelpCommand(registry), "help", "?"); + if (withTutorial) { + Tutorial tutorial = new TutorialLoader().load(); + if (!tutorial.isEmpty()) { + game.setTutorialGuide(new TutorialGuide(tutorial, registry)); + } + } + io.print(Banner.welcome(session.getWorld().getTitle())); io.write(session.getWorld().getWelcomeMessage()); new LookCommand().execute(ctx, List.of()); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 68f5bb1..a56ee9a 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -32,12 +32,23 @@ public class Game { /** Loop flag. Flipped to false by {@link #stop()}. */ private boolean running = true; + /** Optional onboarding guide; null in normal/loaded play. */ + private TutorialGuide tutorialGuide; + + /** Wires the interactive tutorial (New Game only). */ + public void setTutorialGuide(TutorialGuide tutorialGuide) { + this.tutorialGuide = tutorialGuide; + } + /** * Runs the loop until {@link #stop()} is called or input is exhausted. */ public void run() { publishHud(); maybeEnd(); + if (tutorialGuide != null) { + tutorialGuide.begin(ctx); + } while (running) { String input = ctx.getIo().readLine(); if (input == null) { @@ -47,6 +58,12 @@ public class Game { if (parsed.verb().isEmpty()) { continue; } + if (tutorialGuide != null && tutorialGuide.isActive() + && parsed.verb().equals("skip")) { + tutorialGuide.skip(ctx); + publishHud(); + continue; + } Optional cmd = registry.find(parsed.verb()); if (cmd.isEmpty()) { ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); @@ -56,6 +73,9 @@ public class Game { if (ctx.getSession().getTurn() % 10 == 0) { ctx.save(); } + if (tutorialGuide != null) { + tutorialGuide.onCommand(ctx, parsed); + } } publishHud(); maybeEnd(); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java new file mode 100644 index 0000000..43a10ea --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java @@ -0,0 +1,47 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; +import thb.jeanluc.adventure.model.World; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameTutorialTest { + + @Test + void guideBeginsAndAdvancesThenSkipEndsViaLoop() { + Room kitchen = new Room("kitchen", "Kitchen", "d"); + World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(), "t", "w"); + TestIO io = new TestIO(); + // scripted player input: a matching 'look', then 'skip'; then EOF (null) ends run() + io.enqueue("look").enqueue("skip"); + GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io); + + CommandRegistry registry = new CommandRegistry(); + registry.register(new LookCommand(), "look", "l"); + + Tutorial t = new Tutorial("INTRO", + List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint"), + new TutorialStep("do look again", "look", 0, "STEP2-OK", "hint")), + "TIPS"); + Game game = new Game(ctx, registry, new CommandParser()); + game.setTutorialGuide(new TutorialGuide(t, registry)); + + game.run(); + + String out = io.allOutput(); + assertThat(out).contains("INTRO"); // begin printed + assertThat(out).contains("STEP1-OK"); // first 'look' advanced step 1 + assertThat(out).contains("Tutorial skipped"); // 'skip' ended it before step 2 finished + } +} From 19ea7667b001cb84136cc2be37562784ba735da5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:59:07 +0200 Subject: [PATCH 8/9] refactor: consistent isActive() guard on tutorial onCommand; tidy App imports --- Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java | 4 ++-- .../src/main/java/thb/jeanluc/adventure/game/Game.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index ff6367d..70daa3d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -22,14 +22,14 @@ import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameSession; import thb.jeanluc.adventure.game.TutorialGuide; -import thb.jeanluc.adventure.loader.TutorialLoader; -import thb.jeanluc.adventure.model.Tutorial; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; +import thb.jeanluc.adventure.loader.TutorialLoader; import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.menu.MainMenu; import thb.jeanluc.adventure.menu.SettingsMenu; +import thb.jeanluc.adventure.model.Tutorial; import thb.jeanluc.adventure.save.SaveException; import thb.jeanluc.adventure.save.SaveService; import thb.jeanluc.adventure.save.SaveSlotInfo; diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index a56ee9a..0e4fd42 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -73,7 +73,7 @@ public class Game { if (ctx.getSession().getTurn() % 10 == 0) { ctx.save(); } - if (tutorialGuide != null) { + if (tutorialGuide != null && tutorialGuide.isActive()) { tutorialGuide.onCommand(ctx, parsed); } } From 1e980a80e295159fa954558c62e9424c4eba05e3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:59:44 +0200 Subject: [PATCH 9/9] docs: note interactive start-of-game tutorial --- Semesterprojekt/docs/enhancement-ideas.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index cbcbb95..71a93e7 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -201,6 +201,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck. > GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms). > **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt. +> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough +> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte +> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel), +> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`, +> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem +> Spiel, nicht beim Laden. + | Entscheidung | Wert | |---|---| | Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |