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:
2026-06-01 22:03:59 +02:00
15 changed files with 587 additions and 3 deletions

View File

@@ -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) |

View File

@@ -21,12 +21,15 @@ 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.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;
@@ -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());

View File

@@ -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<Command> 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.isActive()) {
tutorialGuide.onCommand(ctx, parsed);
}
}
publishHud();
maybeEnd();

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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) {
}

View File

@@ -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
) {
}

View File

@@ -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);
}
}

View File

@@ -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
) {
}

View 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.

View File

@@ -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
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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"

View 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.