From 91f79b15371346ae135d73af12b607ff13fa50a9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:25:48 +0200 Subject: [PATCH 1/7] feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content Co-Authored-By: Claude Sonnet 4.6 --- .../adventure/loader/TutorialLoader.java | 63 +++++++++++++++++++ .../adventure/loader/dto/TutorialDto.java | 7 +++ .../adventure/loader/dto/TutorialStepDto.java | 11 ++++ .../thb/jeanluc/adventure/model/Tutorial.java | 20 ++++++ .../jeanluc/adventure/model/TutorialStep.java | 16 +++++ .../src/main/resources/world/tutorial.yaml | 40 ++++++++++++ .../adventure/loader/TutorialLoaderTest.java | 27 ++++++++ .../src/test/resources/world/tutorial.yaml | 14 +++++ 8 files changed, 198 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java create mode 100644 Semesterprojekt/src/main/resources/world/tutorial.yaml create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java create mode 100644 Semesterprojekt/src/test/resources/world/tutorial.yaml diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java new file mode 100644 index 0000000..7ebabcb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java @@ -0,0 +1,63 @@ +package thb.jeanluc.adventure.loader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import thb.jeanluc.adventure.loader.dto.TutorialDto; +import thb.jeanluc.adventure.loader.dto.TutorialStepDto; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads the optional onboarding tutorial from {@code /tutorial.yaml}. + * Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world + * content. Returns {@link Tutorial#none()} when the file is absent. + */ +public class TutorialLoader { + + /** Default classpath base directory. */ + public static final String DEFAULT_BASE = "/world"; + + private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); + private final String basePath; + + public TutorialLoader() { + this(DEFAULT_BASE); + } + + public TutorialLoader(String basePath) { + this.basePath = basePath; + } + + /** Reads the tutorial; absent file → {@link Tutorial#none()}. */ + public Tutorial load() { + String resource = basePath + "/tutorial.yaml"; + try (InputStream in = getClass().getResourceAsStream(resource)) { + if (in == null) { + return Tutorial.none(); + } + TutorialDto dto = yaml.readValue(in, TutorialDto.class); + if (dto == null) { + return Tutorial.none(); + } + List steps = new ArrayList<>(); + if (dto.steps() != null) { + for (TutorialStepDto s : dto.steps()) { + steps.add(new TutorialStep( + s.instruction(), + s.expect(), + s.minArgs() == null ? 0 : s.minArgs(), + s.confirm(), + s.hint())); + } + } + return new Tutorial(dto.intro(), steps, dto.closingTips()); + } catch (IOException e) { + throw new WorldLoadException("Failed to parse " + resource, e); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java new file mode 100644 index 0000000..ab113eb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java @@ -0,0 +1,7 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of the tutorial document. */ +public record TutorialDto(String intro, List steps, String closingTips) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java new file mode 100644 index 0000000..e3f622b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java @@ -0,0 +1,11 @@ +package thb.jeanluc.adventure.loader.dto; + +/** YAML representation of a tutorial step. */ +public record TutorialStepDto( + String instruction, + String expect, + Integer minArgs, + String confirm, + String hint +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java new file mode 100644 index 0000000..338b2e2 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Tutorial.java @@ -0,0 +1,20 @@ +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */ +public record Tutorial(String intro, List steps, String closingTips) { + + public Tutorial { + steps = steps == null ? List.of() : List.copyOf(steps); + } + + public boolean isEmpty() { + return steps.isEmpty(); + } + + /** An absent tutorial (no steps). */ + public static Tutorial none() { + return new Tutorial(null, List.of(), null); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java new file mode 100644 index 0000000..e32b587 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/TutorialStep.java @@ -0,0 +1,16 @@ +package thb.jeanluc.adventure.model; + +/** + * One interactive tutorial step: an instruction, the command that satisfies it + * ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"), + * a minimum argument count, a success confirmation, and a hint for a valid but + * wrong command. + */ +public record TutorialStep( + String instruction, + String expect, + int minArgs, + String confirm, + String hint +) { +} diff --git a/Semesterprojekt/src/main/resources/world/tutorial.yaml b/Semesterprojekt/src/main/resources/world/tutorial.yaml new file mode 100644 index 0000000..1dd7573 --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/tutorial.yaml @@ -0,0 +1,40 @@ +intro: | + Welcome to the manor. Let me show you the ropes — just type the commands as + I describe them. (Type 'skip' at any time to jump straight into the game.) +steps: + - instruction: "First, look around the room: type 'look'." + expect: look + confirm: "Good — 'look' (or 'l') re-describes wherever you are." + hint: "When you're ready, just type: look" + - instruction: "Examine something you noticed: 'examine ' (or 'x ')." + expect: examine + minArgs: 1 + confirm: "That's how you inspect items, exits, and people up close." + hint: "Try examining something, e.g.: examine lamp" + - instruction: "Pick something up: 'take '." + expect: take + minArgs: 1 + confirm: "Taken. Things you carry go into your inventory." + hint: "Pick something up, e.g.: take lamp" + - instruction: "Check what you're carrying: 'inventory' (or 'i')." + expect: inventory + confirm: "That's your inventory." + hint: "Type: inventory" + - instruction: "Use an item: 'use ' — try lighting the lamp." + expect: use + minArgs: 1 + confirm: "Some items toggle, read, or react when you use them." + hint: "Use something, e.g.: use lamp" + - instruction: "Now move to another room: 'go ' (try 'go north')." + expect: go_direction + confirm: "You can move north / south / east / west between connected rooms." + hint: "Head to a connected room, e.g.: go north" + - instruction: "Travel back to a room you've already seen: 'go to '." + expect: go_to_room + confirm: "Nice — 'go to ' auto-walks to any room you've already visited." + hint: "Try: go to " +closingTips: | + That's the core. A few more you'll want: + read · drop · talk to · give to · + use on (combine things) · map · quests · save · menu. + Type 'help' anytime. Good luck. diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java new file mode 100644 index 0000000..937306b --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.Tutorial; + +import static org.assertj.core.api.Assertions.assertThat; + +class TutorialLoaderTest { + + @Test + void loadsTheTestFixture() { + Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod + assertThat(t.isEmpty()).isFalse(); + assertThat(t.intro()).isNotBlank(); + assertThat(t.closingTips()).isNotBlank(); + assertThat(t.steps()).hasSize(2); + assertThat(t.steps().getFirst().expect()).isEqualTo("look"); + assertThat(t.steps().getFirst().minArgs()).isZero(); + } + + @Test + void missingFileReturnsEmptyTutorial() { + Tutorial t = new TutorialLoader("/no-such-base").load(); + assertThat(t.isEmpty()).isTrue(); + assertThat(t.steps()).isEmpty(); + } +} diff --git a/Semesterprojekt/src/test/resources/world/tutorial.yaml b/Semesterprojekt/src/test/resources/world/tutorial.yaml new file mode 100644 index 0000000..950bf44 --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/tutorial.yaml @@ -0,0 +1,14 @@ +intro: | + Test intro. +steps: + - instruction: "Look: type 'look'." + expect: look + confirm: "Looked." + hint: "Type look." + - instruction: "Take: type 'take '." + expect: take + minArgs: 1 + confirm: "Took it." + hint: "Take something." +closingTips: | + Test tips. From dbfdf8681002f775f9b0916ff746ae8efd42e675 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:40:06 +0200 Subject: [PATCH 2/7] test(tutorial): cover malformed tutorial.yaml -> WorldLoadException --- .../thb/jeanluc/adventure/loader/TutorialLoaderTest.java | 8 ++++++++ Semesterprojekt/src/test/resources/badtut/tutorial.yaml | 5 +++++ 2 files changed, 13 insertions(+) create mode 100644 Semesterprojekt/src/test/resources/badtut/tutorial.yaml diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java index 937306b..a167f6e 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.model.Tutorial; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class TutorialLoaderTest { @@ -24,4 +25,11 @@ class TutorialLoaderTest { assertThat(t.isEmpty()).isTrue(); assertThat(t.steps()).isEmpty(); } + + @Test + void malformedFileThrowsWorldLoadException() { + // /badtut/tutorial.yaml has 'steps: 12345' (scalar where a list is required) + assertThatThrownBy(() -> new TutorialLoader("/badtut").load()) + .isInstanceOf(WorldLoadException.class); + } } diff --git a/Semesterprojekt/src/test/resources/badtut/tutorial.yaml b/Semesterprojekt/src/test/resources/badtut/tutorial.yaml new file mode 100644 index 0000000..97dcc56 --- /dev/null +++ b/Semesterprojekt/src/test/resources/badtut/tutorial.yaml @@ -0,0 +1,5 @@ +# Malformed on purpose: 'steps' must be a list, not a scalar. +# Used by TutorialLoaderTest to verify parse errors become WorldLoadException. +intro: "bad" +steps: 12345 +closingTips: "bad" From af319508f6371171f4c6a4b8752bc959bde4d480 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:42:30 +0200 Subject: [PATCH 3/7] feat(game): TutorialGuide interactive walkthrough engine Alias-aware, stateful engine with verb/minArgs matching, go_direction/go_to_room room-change detection, skip, and closing-tips on completion. 7 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanluc/adventure/game/TutorialGuide.java | 119 +++++++++++++++ .../adventure/game/TutorialGuideTest.java | 143 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java new file mode 100644 index 0000000..2ac91aa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java @@ -0,0 +1,119 @@ +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; + +import java.util.Optional; + +/** + * Drives the interactive start-of-game walkthrough, layered onto the real game + * loop. Prints an instruction, then on each executed command decides whether the + * current step is satisfied (confirm + advance) or not (hint). Movement steps + * require an actual room change. Alias-aware via the {@link CommandRegistry}. + */ +public final class TutorialGuide { + + private final Tutorial tutorial; + private final CommandRegistry registry; + private int index; + private boolean active; + private Room roomAtStepStart; + + public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { + this.tutorial = tutorial; + this.registry = registry; + this.active = !tutorial.isEmpty(); + } + + public boolean isActive() { + return active; + } + + /** Prints the intro and the first instruction (call once at loop start). */ + public void begin(GameContext ctx) { + if (!active) { + return; + } + if (tutorial.intro() != null && !tutorial.intro().isBlank()) { + ctx.getIo().write(tutorial.intro()); + } + enterStep(ctx); + } + + /** Evaluates the current step against the command that was just executed. */ + public void onCommand(GameContext ctx, ParsedCommand parsed) { + if (!active) { + return; + } + TutorialStep step = tutorial.steps().get(index); + if (satisfies(step, ctx, parsed)) { + ctx.getIo().write(step.confirm()); + index++; + if (index >= tutorial.steps().size()) { + if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) { + ctx.getIo().write(tutorial.closingTips()); + } + active = false; + } else { + enterStep(ctx); + } + } else { + ctx.getIo().write(step.hint()); + } + } + + /** Ends the tutorial early (the 'skip' command). */ + public void skip(GameContext ctx) { + if (active) { + ctx.getIo().write("Tutorial skipped."); + active = false; + } + } + + private void enterStep(GameContext ctx) { + roomAtStepStart = ctx.getPlayer().getCurrentRoom(); + ctx.getIo().write(tutorial.steps().get(index).instruction()); + } + + private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) { + String expect = step.expect(); + if ("go_direction".equals(expect)) { + return isGo(parsed) && firstIsDirection(parsed) && moved(ctx); + } + if ("go_to_room".equals(expect)) { + return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx); + } + return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs(); + } + + private boolean isGo(ParsedCommand p) { + return sameCommand(p.verb(), "go") && !p.args().isEmpty(); + } + + private boolean firstIsDirection(ParsedCommand p) { + if (p.args().isEmpty()) { + return false; + } + try { + Direction.fromString(p.args().get(0)); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + private boolean moved(GameContext ctx) { + return ctx.getPlayer().getCurrentRoom() != roomAtStepStart; + } + + private boolean sameCommand(String a, String b) { + Optional ca = registry.find(a); + Optional cb = registry.find(b); + return ca.isPresent() && cb.isPresent() && ca.get() == cb.get(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java new file mode 100644 index 0000000..84b06f5 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java @@ -0,0 +1,143 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.command.impl.ExamineCommand; +import thb.jeanluc.adventure.command.impl.GoCommand; +import thb.jeanluc.adventure.command.impl.InventoryCommand; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.command.impl.TakeCommand; +import thb.jeanluc.adventure.command.impl.UseCommand; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; +import thb.jeanluc.adventure.model.World; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TutorialGuideTest { + + private CommandRegistry registry() { + CommandRegistry r = new CommandRegistry(); + r.register(new LookCommand(), "look", "l"); + r.register(new ExamineCommand(), "examine", "x"); + r.register(new TakeCommand(), "take", "get"); + r.register(new InventoryCommand(), "inventory", "i"); + r.register(new UseCommand(), "use"); + r.register(new GoCommand(), "go", "move", "walk"); + return r; + } + + private final Room kitchen = new Room("kitchen", "Kitchen", "d"); + private final Room hallway = new Room("hallway", "Hallway", "d"); + + private GameContext ctx() { + kitchen.addExit(Direction.NORTH, hallway); + Map rooms = new LinkedHashMap<>(); + rooms.put("kitchen", kitchen); + rooms.put("hallway", hallway); + World w = new World(rooms, Map.of(), Map.of(), "t", "w"); + Player p = new Player(kitchen, 0); + return new GameContext(w, p, new TestIO()); + } + + private ParsedCommand parse(String s) { + return new CommandParser().parse(s); + } + + private String out(GameContext ctx) { + return ((TestIO) ctx.getIo()).allOutput(); + } + + private TutorialStep step(String instr, String expect, int minArgs) { + return new TutorialStep(instr, expect, minArgs, "OK:" + expect, "HINT:" + expect); + } + + @Test + void beginPrintsIntroAndFirstInstruction() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + assertThat(out(ctx)).contains("INTRO").contains("do look"); + assertThat(g.isActive()).isTrue(); + } + + @Test + void aliasSatisfiesVerbStepAndAdvancesToTips() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("l")); // alias of look + assertThat(out(ctx)).contains("OK:look").contains("TIPS"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void minArgsGatesTakeStep() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("take it", "take", 1)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("take")); // no arg -> hint, not satisfied + assertThat(out(ctx)).contains("HINT:take"); + assertThat(g.isActive()).isTrue(); + g.onCommand(ctx, parse("take lamp")); // satisfied + assertThat(out(ctx)).contains("OK:take"); + } + + @Test + void goDirectionRequiresAnActualMove() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("move", "go_direction", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + // typed a direction but did NOT move -> hint + g.onCommand(ctx, parse("go north")); + assertThat(out(ctx)).contains("HINT:go_direction"); + assertThat(g.isActive()).isTrue(); + // now actually move, then the same command satisfies + ctx.getPlayer().setCurrentRoom(hallway); + g.onCommand(ctx, parse("go north")); + assertThat(out(ctx)).contains("OK:go_direction"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void goToRoomNeedsNonDirectionArgAndMove() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + ctx.getPlayer().setCurrentRoom(hallway); // moved + g.onCommand(ctx, parse("go kitchen")); // "kitchen" is not a direction + assertThat(out(ctx)).contains("OK:go_to_room"); + } + + @Test + void skipDeactivates() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("do look", "look", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.skip(ctx); + assertThat(out(ctx)).contains("Tutorial skipped"); + assertThat(g.isActive()).isFalse(); + } + + @Test + void emptyTutorialIsInactive() { + TutorialGuide g = new TutorialGuide(Tutorial.none(), registry()); + assertThat(g.isActive()).isFalse(); + } +} From e27a12e4fe0c472e57579ebb47c926465e3e0c36 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:49:47 +0200 Subject: [PATCH 4/7] fix(game): null-safe moved() + idempotent begin(); cover multi-step + go_to_room hint Co-Authored-By: Claude Sonnet 4.6 --- .../jeanluc/adventure/game/TutorialGuide.java | 8 +++++-- .../adventure/game/TutorialGuideTest.java | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java index 2ac91aa..a96a2f7 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java @@ -22,6 +22,7 @@ public final class TutorialGuide { private final CommandRegistry registry; private int index; private boolean active; + private boolean begun; private Room roomAtStepStart; public TutorialGuide(Tutorial tutorial, CommandRegistry registry) { @@ -30,15 +31,17 @@ public final class TutorialGuide { this.active = !tutorial.isEmpty(); } + /** @return true while the walkthrough is still running. */ public boolean isActive() { return active; } /** Prints the intro and the first instruction (call once at loop start). */ public void begin(GameContext ctx) { - if (!active) { + if (!active || begun) { return; } + begun = true; if (tutorial.intro() != null && !tutorial.intro().isBlank()) { ctx.getIo().write(tutorial.intro()); } @@ -108,7 +111,8 @@ public final class TutorialGuide { } private boolean moved(GameContext ctx) { - return ctx.getPlayer().getCurrentRoom() != roomAtStepStart; + return roomAtStepStart != null + && ctx.getPlayer().getCurrentRoom() != roomAtStepStart; } private boolean sameCommand(String a, String b) { diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java index 84b06f5..529f0e7 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java @@ -140,4 +140,27 @@ class TutorialGuideTest { TutorialGuide g = new TutorialGuide(Tutorial.none(), registry()); assertThat(g.isActive()).isFalse(); } + + @Test + void advancingPrintsTheNextInstruction() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", + List.of(step("do look", "look", 0), step("do take", "take", 1)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("look")); + assertThat(out(ctx)).contains("OK:look").contains("do take"); + assertThat(g.isActive()).isTrue(); + } + + @Test + void goToRoomWithoutMovingGivesHint() { + GameContext ctx = ctx(); + Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS"); + TutorialGuide g = new TutorialGuide(t, registry()); + g.begin(ctx); + g.onCommand(ctx, parse("go kitchen")); // non-direction arg but no room change + assertThat(out(ctx)).contains("HINT:go_to_room"); + assertThat(g.isActive()).isTrue(); + } } From c315d3f4b78a4429581dc24b6f8432afa7de6692 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:53:27 +0200 Subject: [PATCH 5/7] feat: wire TutorialGuide into the game loop; show on New Game only Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/thb/jeanluc/adventure/App.java | 16 +++++-- .../java/thb/jeanluc/adventure/game/Game.java | 20 ++++++++ .../adventure/game/GameTutorialTest.java | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index 8444308..ff6367d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -21,6 +21,9 @@ import thb.jeanluc.adventure.command.impl.UseCommand; import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.game.TutorialGuide; +import thb.jeanluc.adventure.loader.TutorialLoader; +import thb.jeanluc.adventure.model.Tutorial; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; @@ -70,12 +73,12 @@ public final class App { boolean running = true; while (running) { switch (MainMenu.show(io)) { - case NEW_GAME -> play(io, saves, newSession(io)); + case NEW_GAME -> play(io, saves, newSession(io), true); case LOAD -> { SaveSlotInfo slot = MainMenu.pickSlot(io, saves); if (slot != null) { try { - play(io, saves, saves.load(slot.slug())); + play(io, saves, saves.load(slot.slug()), false); } catch (SaveException e) { io.write("Could not load: " + e.getUserMessage()); } @@ -96,7 +99,7 @@ public final class App { } /** Builds the registry, runs one game to completion, autosaving to its slot. */ - private static void play(GameIO io, SaveService saves, GameSession session) { + private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) { GameContext ctx = new GameContext(session, io); ctx.setSaveCallback(() -> { try { @@ -128,6 +131,13 @@ public final class App { registry.register(menu, "menu", "quit", "exit"); registry.register(new HelpCommand(registry), "help", "?"); + if (withTutorial) { + Tutorial tutorial = new TutorialLoader().load(); + if (!tutorial.isEmpty()) { + game.setTutorialGuide(new TutorialGuide(tutorial, registry)); + } + } + io.print(Banner.welcome(session.getWorld().getTitle())); io.write(session.getWorld().getWelcomeMessage()); new LookCommand().execute(ctx, List.of()); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 68f5bb1..a56ee9a 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -32,12 +32,23 @@ public class Game { /** Loop flag. Flipped to false by {@link #stop()}. */ private boolean running = true; + /** Optional onboarding guide; null in normal/loaded play. */ + private TutorialGuide tutorialGuide; + + /** Wires the interactive tutorial (New Game only). */ + public void setTutorialGuide(TutorialGuide tutorialGuide) { + this.tutorialGuide = tutorialGuide; + } + /** * Runs the loop until {@link #stop()} is called or input is exhausted. */ public void run() { publishHud(); maybeEnd(); + if (tutorialGuide != null) { + tutorialGuide.begin(ctx); + } while (running) { String input = ctx.getIo().readLine(); if (input == null) { @@ -47,6 +58,12 @@ public class Game { if (parsed.verb().isEmpty()) { continue; } + if (tutorialGuide != null && tutorialGuide.isActive() + && parsed.verb().equals("skip")) { + tutorialGuide.skip(ctx); + publishHud(); + continue; + } Optional cmd = registry.find(parsed.verb()); if (cmd.isEmpty()) { ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); @@ -56,6 +73,9 @@ public class Game { if (ctx.getSession().getTurn() % 10 == 0) { ctx.save(); } + if (tutorialGuide != null) { + tutorialGuide.onCommand(ctx, parsed); + } } publishHud(); maybeEnd(); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java new file mode 100644 index 0000000..43a10ea --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java @@ -0,0 +1,47 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.Tutorial; +import thb.jeanluc.adventure.model.TutorialStep; +import thb.jeanluc.adventure.model.World; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameTutorialTest { + + @Test + void guideBeginsAndAdvancesThenSkipEndsViaLoop() { + Room kitchen = new Room("kitchen", "Kitchen", "d"); + World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(), "t", "w"); + TestIO io = new TestIO(); + // scripted player input: a matching 'look', then 'skip'; then EOF (null) ends run() + io.enqueue("look").enqueue("skip"); + GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io); + + CommandRegistry registry = new CommandRegistry(); + registry.register(new LookCommand(), "look", "l"); + + Tutorial t = new Tutorial("INTRO", + List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint"), + new TutorialStep("do look again", "look", 0, "STEP2-OK", "hint")), + "TIPS"); + Game game = new Game(ctx, registry, new CommandParser()); + game.setTutorialGuide(new TutorialGuide(t, registry)); + + game.run(); + + String out = io.allOutput(); + assertThat(out).contains("INTRO"); // begin printed + assertThat(out).contains("STEP1-OK"); // first 'look' advanced step 1 + assertThat(out).contains("Tutorial skipped"); // 'skip' ended it before step 2 finished + } +} From 236ed0a180029f07e93c00f4fdeca6739c796305 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:59:07 +0200 Subject: [PATCH 6/7] refactor: consistent isActive() guard on tutorial onCommand; tidy App imports --- Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java | 4 ++-- .../src/main/java/thb/jeanluc/adventure/game/Game.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index ff6367d..70daa3d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -22,14 +22,14 @@ import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameSession; import thb.jeanluc.adventure.game.TutorialGuide; -import thb.jeanluc.adventure.loader.TutorialLoader; -import thb.jeanluc.adventure.model.Tutorial; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.text.Banner; +import thb.jeanluc.adventure.loader.TutorialLoader; import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.menu.MainMenu; import thb.jeanluc.adventure.menu.SettingsMenu; +import thb.jeanluc.adventure.model.Tutorial; import thb.jeanluc.adventure.save.SaveException; import thb.jeanluc.adventure.save.SaveService; import thb.jeanluc.adventure.save.SaveSlotInfo; diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index a56ee9a..0e4fd42 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -73,7 +73,7 @@ public class Game { if (ctx.getSession().getTurn() % 10 == 0) { ctx.save(); } - if (tutorialGuide != null) { + if (tutorialGuide != null && tutorialGuide.isActive()) { tutorialGuide.onCommand(ctx, parsed); } } From 1ff475c08575fc9b98983bfff3d9e0dbd57fc515 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 21:59:44 +0200 Subject: [PATCH 7/7] docs: note interactive start-of-game tutorial --- Semesterprojekt/docs/enhancement-ideas.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index cbcbb95..71a93e7 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -201,6 +201,13 @@ vorerst puzzle-fokussiert ohne Survival-Druck. > GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms). > **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt. +> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough +> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte +> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel), +> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`, +> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem +> Spiel, nicht beim Laden. + | Entscheidung | Wert | |---|---| | Eigene `uebung`-Datenstrukturen verwenden? | **Nein** – Standard-Collections (vom Prof bestätigt) |