Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md
Jean-Luc Makiola 5c9a8cb568 docs: implementation plan for interactive start tutorial
4 TDD tasks: Tutorial model+loader+tutorial.yaml, TutorialGuide engine,
Game+App wiring (New Game only), docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:23:06 +02:00

28 KiB

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.yamlTutorial.
  • 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.javaplay(..., 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:

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:

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:

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:

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:

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:

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:

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

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
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:

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:

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
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:

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:
import thb.jeanluc.adventure.game.TutorialGuide;

(Same package — skip the import; reference TutorialGuide directly.)

  1. Add a field after 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;
    }
  1. In run(), after the initial publishHud(); maybeEnd(); and before while (running) {:
        if (tutorialGuide != null) {
            tutorialGuide.begin(ctx);
        }
  1. 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):
            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:
import thb.jeanluc.adventure.game.TutorialGuide;
import thb.jeanluc.adventure.loader.TutorialLoader;
import thb.jeanluc.adventure.model.Tutorial;
  1. Change the two play(...) call sites in run():
                case NEW_GAME -> play(io, saves, newSession(io), true);

and inside the LOAD branch:

                            play(io, saves, saves.load(slot.slug()), false);
  1. Change the play signature and add the guide wiring (after the HelpCommand is registered, before the banner):
    private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {

and, immediately after registry.register(new HelpCommand(registry), "help", "?");:

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