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