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) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:42:30 +02:00
parent dbfdf86810
commit af319508f6
2 changed files with 262 additions and 0 deletions

View File

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