Merge interactive start-of-game tutorial into develop
A skippable interactive walkthrough shown on New Game: a TutorialGuide layered onto the real game loop makes the player perform the core commands (look→examine→take→inventory→use→go→go to) in the starting room, with verb-/ alias-aware completion (movement steps require an actual room change), gentle hints on wrong-but-valid input, and closing tips for the rest. Data-driven (tutorial.yaml + standalone TutorialLoader); 'skip' ends it; loaded games never show it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
|
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
|
||||||
> **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt.
|
> **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 |
|
| Entscheidung | Wert |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |
|
| Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |
|
||||||
|
|||||||
822
Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md
Normal file
822
Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md
Normal file
@@ -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<TutorialStep> 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<TutorialStepDto> 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 <base>/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<TutorialStep> 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 <thing>' (or 'x <thing>')."
|
||||||
|
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 <item>'."
|
||||||
|
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 <item>' — 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 <direction>' (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 <room>'."
|
||||||
|
expect: go_to_room
|
||||||
|
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
|
||||||
|
hint: "Try: go to <a room you've already visited>"
|
||||||
|
closingTips: |
|
||||||
|
That's the core. A few more you'll want:
|
||||||
|
read · drop · talk to <npc> · give <item> to <npc> ·
|
||||||
|
use <item> on <item> (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 <item>'."
|
||||||
|
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<String, Room> 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<Command> ca = registry.find(a);
|
||||||
|
Optional<Command> 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<Command> 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.
|
||||||
|
```
|
||||||
@@ -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<TutorialStep> 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 <thing>' (or 'x <thing>')."
|
||||||
|
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 <item>'."
|
||||||
|
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 <item>' (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 <direction>' (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 <room>'."
|
||||||
|
expect: go_to_room
|
||||||
|
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
|
||||||
|
hint: "Try: go to <a room you've already visited>"
|
||||||
|
closingTips: |
|
||||||
|
That's the core. A few more you'll want:
|
||||||
|
read · drop · talk to <npc> · give <item> to <npc> ·
|
||||||
|
use <item> on <item> (combine things) · map · quests · save · menu.
|
||||||
|
Type 'help' anytime. Good luck.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. `TutorialLoader` (loader)
|
||||||
|
|
||||||
|
- `loader/dto/TutorialDto` (`intro`, `List<TutorialStepDto> 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.
|
||||||
@@ -21,12 +21,15 @@ import thb.jeanluc.adventure.command.impl.UseCommand;
|
|||||||
import thb.jeanluc.adventure.game.Game;
|
import thb.jeanluc.adventure.game.Game;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.GameSession;
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
import thb.jeanluc.adventure.io.GameIO;
|
import thb.jeanluc.adventure.io.GameIO;
|
||||||
import thb.jeanluc.adventure.io.text.Banner;
|
import thb.jeanluc.adventure.io.text.Banner;
|
||||||
|
import thb.jeanluc.adventure.loader.TutorialLoader;
|
||||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||||
import thb.jeanluc.adventure.menu.MainMenu;
|
import thb.jeanluc.adventure.menu.MainMenu;
|
||||||
import thb.jeanluc.adventure.menu.SettingsMenu;
|
import thb.jeanluc.adventure.menu.SettingsMenu;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
import thb.jeanluc.adventure.save.SaveException;
|
import thb.jeanluc.adventure.save.SaveException;
|
||||||
import thb.jeanluc.adventure.save.SaveService;
|
import thb.jeanluc.adventure.save.SaveService;
|
||||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||||
@@ -70,12 +73,12 @@ public final class App {
|
|||||||
boolean running = true;
|
boolean running = true;
|
||||||
while (running) {
|
while (running) {
|
||||||
switch (MainMenu.show(io)) {
|
switch (MainMenu.show(io)) {
|
||||||
case NEW_GAME -> play(io, saves, newSession(io));
|
case NEW_GAME -> play(io, saves, newSession(io), true);
|
||||||
case LOAD -> {
|
case LOAD -> {
|
||||||
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
||||||
if (slot != null) {
|
if (slot != null) {
|
||||||
try {
|
try {
|
||||||
play(io, saves, saves.load(slot.slug()));
|
play(io, saves, saves.load(slot.slug()), false);
|
||||||
} catch (SaveException e) {
|
} catch (SaveException e) {
|
||||||
io.write("Could not load: " + e.getUserMessage());
|
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. */
|
/** 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);
|
GameContext ctx = new GameContext(session, io);
|
||||||
ctx.setSaveCallback(() -> {
|
ctx.setSaveCallback(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -128,6 +131,13 @@ public final class App {
|
|||||||
registry.register(menu, "menu", "quit", "exit");
|
registry.register(menu, "menu", "quit", "exit");
|
||||||
registry.register(new HelpCommand(registry), "help", "?");
|
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.print(Banner.welcome(session.getWorld().getTitle()));
|
||||||
io.write(session.getWorld().getWelcomeMessage());
|
io.write(session.getWorld().getWelcomeMessage());
|
||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
|||||||
@@ -32,12 +32,23 @@ public class Game {
|
|||||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||||
private boolean running = true;
|
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.
|
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
||||||
*/
|
*/
|
||||||
public void run() {
|
public void run() {
|
||||||
publishHud();
|
publishHud();
|
||||||
maybeEnd();
|
maybeEnd();
|
||||||
|
if (tutorialGuide != null) {
|
||||||
|
tutorialGuide.begin(ctx);
|
||||||
|
}
|
||||||
while (running) {
|
while (running) {
|
||||||
String input = ctx.getIo().readLine();
|
String input = ctx.getIo().readLine();
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -47,6 +58,12 @@ public class Game {
|
|||||||
if (parsed.verb().isEmpty()) {
|
if (parsed.verb().isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()
|
||||||
|
&& parsed.verb().equals("skip")) {
|
||||||
|
tutorialGuide.skip(ctx);
|
||||||
|
publishHud();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Optional<Command> cmd = registry.find(parsed.verb());
|
Optional<Command> cmd = registry.find(parsed.verb());
|
||||||
if (cmd.isEmpty()) {
|
if (cmd.isEmpty()) {
|
||||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||||
@@ -56,6 +73,9 @@ public class Game {
|
|||||||
if (ctx.getSession().getTurn() % 10 == 0) {
|
if (ctx.getSession().getTurn() % 10 == 0) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()) {
|
||||||
|
tutorialGuide.onCommand(ctx, parsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
publishHud();
|
publishHud();
|
||||||
maybeEnd();
|
maybeEnd();
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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 boolean begun;
|
||||||
|
private Room roomAtStepStart;
|
||||||
|
|
||||||
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
|
this.tutorial = tutorial;
|
||||||
|
this.registry = registry;
|
||||||
|
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 || begun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
begun = true;
|
||||||
|
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 roomAtStepStart != null
|
||||||
|
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameCommand(String a, String b) {
|
||||||
|
Optional<Command> ca = registry.find(a);
|
||||||
|
Optional<Command> cb = registry.find(b);
|
||||||
|
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <base>/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<TutorialStep> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TutorialStepDto> steps, String closingTips) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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<TutorialStep> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
40
Semesterprojekt/src/main/resources/world/tutorial.yaml
Normal file
40
Semesterprojekt/src/main/resources/world/tutorial.yaml
Normal file
@@ -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 <thing>' (or 'x <thing>')."
|
||||||
|
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 <item>'."
|
||||||
|
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 <item>' — 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 <direction>' (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 <room>'."
|
||||||
|
expect: go_to_room
|
||||||
|
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
|
||||||
|
hint: "Try: go to <a room you've already visited>"
|
||||||
|
closingTips: |
|
||||||
|
That's the core. A few more you'll want:
|
||||||
|
read · drop · talk to <npc> · give <item> to <npc> ·
|
||||||
|
use <item> on <item> (combine things) · map · quests · save · menu.
|
||||||
|
Type 'help' anytime. Good luck.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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<String, Room> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedFileThrowsWorldLoadException() {
|
||||||
|
// /badtut/tutorial.yaml has 'steps: 12345' (scalar where a list is required)
|
||||||
|
assertThatThrownBy(() -> new TutorialLoader("/badtut").load())
|
||||||
|
.isInstanceOf(WorldLoadException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Semesterprojekt/src/test/resources/badtut/tutorial.yaml
Normal file
5
Semesterprojekt/src/test/resources/badtut/tutorial.yaml
Normal file
@@ -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"
|
||||||
14
Semesterprojekt/src/test/resources/world/tutorial.yaml
Normal file
14
Semesterprojekt/src/test/resources/world/tutorial.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
intro: |
|
||||||
|
Test intro.
|
||||||
|
steps:
|
||||||
|
- instruction: "Look: type 'look'."
|
||||||
|
expect: look
|
||||||
|
confirm: "Looked."
|
||||||
|
hint: "Type look."
|
||||||
|
- instruction: "Take: type 'take <item>'."
|
||||||
|
expect: take
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Took it."
|
||||||
|
hint: "Take something."
|
||||||
|
closingTips: |
|
||||||
|
Test tips.
|
||||||
Reference in New Issue
Block a user