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 f47a3e7ca5
commit 546a7672bf
2 changed files with 262 additions and 0 deletions

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