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:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user