# `go to ` Pathfinding Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add `go to ` — BFS auto-walk over visited rooms (respecting locked exits + darkness), routed through `GoCommand`, with a silent console summary and an animated GUI minimap. **Architecture:** A new stateless `Pathfinder` (game package, next to `Conditions`/`Effects`/`Light`) does BFS over visited rooms using the same passability gates `GoCommand` enforces. `GoCommand` gains a branch: a directional arg moves one step (unchanged); a non-direction arg resolves a visited room by id/name and auto-walks the path. Per step it pushes a map frame via a new `GameIO.travelStep(MapView)` hook — a console no-op, overridden by `SwingIO` to repaint + briefly sleep so the minimap animates. **Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok. --- ## File Structure **Create:** - `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java` — BFS over visited rooms. **Modify:** - `io/GameIO.java` — add `travelStep(MapView)` default no-op. - `io/SwingIO.java` — override `travelStep` (setMap + ~300ms sleep). - `command/impl/GoCommand.java` — extract `moveDirection`; add `goToRoom` (resolve + path + walk + summary). - `docs/enhancement-ideas.md` — mark pathfinding implemented. --- ## Task 1: `GameIO.travelStep` hook + `SwingIO` animation **Files:** - Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java` - Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` - Test: `src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java` - [ ] **Step 1: Write the failing test** `src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java`: ```java package thb.jeanluc.adventure.io; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.io.text.MapView; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class TravelStepDefaultTest { @Test void defaultTravelStepIsSilentNoOp() { TestIO io = new TestIO(); // An empty map view is fine; the default must not print or throw. io.travelStep(new MapView(List.of(), List.of())); assertThat(io.outputs()).isEmpty(); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `mvn -q test -Dtest=TravelStepDefaultTest` Expected: FAIL — `travelStep` is not defined on `GameIO`. - [ ] **Step 3: Add the default to `GameIO`** In `src/main/java/thb/jeanluc/adventure/io/GameIO.java`, add the import (if missing it is already there — `MapView` is used by `setMap`) and the method: ```java /** * One frame of an auto-walk (the {@code go to} command), as the player moves * through a room en route. Default is a no-op: text mode travels silently. * The GUI overrides this to repaint the map and pace the animation. * * @param view the map snapshot at this step */ default void travelStep(MapView view) { // no animation in text mode } ``` - [ ] **Step 4: Override in `SwingIO`** In `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`: 1. Add a constant near the other size constants (e.g. below `SMALL_SIZE`): ```java /** Delay between rooms while animating a go-to auto-walk, in milliseconds. */ private static final long TRAVEL_STEP_MS = 300L; ``` 2. Add the override (place it near `setMap`): ```java @Override public void travelStep(MapView view) { setMap(view); try { Thread.sleep(TRAVEL_STEP_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } ``` (`setMap` already calls `map.show(view)` which repaints on the EDT; the sleep on the game-loop worker thread lets each frame render. `MapView` is already imported in `SwingIO`.) - [ ] **Step 5: Run tests** Run: `mvn -q test -Dtest=TravelStepDefaultTest` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \ src/main/java/thb/jeanluc/adventure/io/SwingIO.java \ src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java git commit -m "feat(io): travelStep hook for animated auto-walk (console no-op, Swing override)" ``` --- ## Task 2: `Pathfinder` BFS **Files:** - Create: `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java` - Test: `src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java` - [ ] **Step 1: Write the failing test** `src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java`: ```java package thb.jeanluc.adventure.game; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.model.Condition; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.PlainItem; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; class PathfinderTest { private Room room(String id) { return new Room(id, id, "d"); } private GameContext ctx(Room start, List all, Set visited) { Map rooms = new LinkedHashMap<>(); for (Room r : all) { rooms.put(r.getId(), r); } World w = new World(rooms, Map.of(), Map.of(), "t", "w"); Player p = new Player(start, 0); p.getVisitedRoomIds().addAll(visited); return new GameContext(w, p, new TestIO()); } @Test void findsShortestPathOverVisitedRooms() { Room a = room("a"), b = room("b"), c = room("c"); a.addExit(Direction.NORTH, b); b.addExit(Direction.NORTH, c); GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "b", "c")); assertThat(Pathfinder.findPath(ctx, c)).containsExactly(b, c); } @Test void willNotRouteThroughUnvisitedRoom() { Room a = room("a"), b = room("b"), c = room("c"); a.addExit(Direction.NORTH, b); b.addExit(Direction.NORTH, c); GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "c")); // b not visited assertThat(Pathfinder.findPath(ctx, c)).isEmpty(); } @Test void respectsLockedExit() { Room a = room("a"), b = room("b"); a.addExit(Direction.NORTH, b); a.addExitLock(Direction.NORTH, new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "locked")); GameContext ctx = ctx(a, List.of(a, b), Set.of("a", "b")); assertThat(Pathfinder.findPath(ctx, b)).isEmpty(); ctx.getState().set("open"); assertThat(Pathfinder.findPath(ctx, b)).containsExactly(b); } @Test void respectsDarkness() { Room a = room("a"); Room dark = room("dark"); dark.setDark(true); a.addExit(Direction.NORTH, dark); GameContext ctx = ctx(a, List.of(a, dark), Set.of("a", "dark")); assertThat(Pathfinder.findPath(ctx, dark)).isEmpty(); Item torch = PlainItem.builder().id("lit").name("Lit").description("d").light(true).build(); ctx.getPlayer().addItem(torch); assertThat(Pathfinder.findPath(ctx, dark)).containsExactly(dark); } @Test void unreachableTargetReturnsEmpty() { Room a = room("a"); Room island = room("island"); // no connecting exits GameContext ctx = ctx(a, List.of(a, island), Set.of("a", "island")); assertThat(Pathfinder.findPath(ctx, island)).isEmpty(); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `mvn -q test -Dtest=PathfinderTest` Expected: FAIL — `Pathfinder` does not exist. - [ ] **Step 3: Create `Pathfinder`** `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java`: ```java package thb.jeanluc.adventure.game; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Room; import java.util.ArrayDeque; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.Set; /** * Breadth-first shortest-path search over the rooms the player has already * visited, honouring the same gates as a manual move: locked exits and * darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/ * {@link Light}. */ public final class Pathfinder { private Pathfinder() { } /** * Finds the shortest path from the player's current room to {@code target}. * * @param ctx active game context * @param target destination room (expected to be a visited room) * @return the rooms to step through, excluding the start and ending at * {@code target}; empty if there is no currently-passable path * (or {@code target} is the current room) */ public static List findPath(GameContext ctx, Room target) { Room start = ctx.getPlayer().getCurrentRoom(); Set visited = ctx.getPlayer().getVisitedRoomIds(); Queue queue = new ArrayDeque<>(); Map cameFrom = new HashMap<>(); queue.add(start); cameFrom.put(start, null); while (!queue.isEmpty()) { Room current = queue.poll(); if (current == target) { return reconstruct(cameFrom, target); } for (Map.Entry exit : current.getExits().entrySet()) { Room next = exit.getValue(); if (cameFrom.containsKey(next) || !visited.contains(next.getId())) { continue; } ExitLock lock = current.getExitLocks().get(exit.getKey()); if (lock != null && !Conditions.all(lock.requires(), ctx)) { continue; } if (!Light.isLit(ctx, next)) { continue; } cameFrom.put(next, current); queue.add(next); } } return List.of(); } /** Walks predecessors back from target to start; returns steps after start. */ private static List reconstruct(Map cameFrom, Room target) { LinkedList path = new LinkedList<>(); for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) { path.addFirst(r); } return path; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `mvn -q test -Dtest=PathfinderTest` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/game/Pathfinder.java \ src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java git commit -m "feat(game): Pathfinder BFS over visited rooms (locks + light aware)" ``` --- ## Task 3: `GoCommand` — route, resolve, walk, summarize **Files:** - Modify: `src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java` - Test: `src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java` - [ ] **Step 1: Write the failing test** `src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java`: ```java package thb.jeanluc.adventure.command.impl; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.model.Condition; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Room; 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 GoCommandPathTest { /** kitchen --N--> hallway --N--> library (all lit). Optional lock on kitchen->hallway. */ private GameContext world(boolean lockHallway, String libraryName) { Room kitchen = new Room("kitchen", "Kitchen", "d"); Room hallway = new Room("hallway", "Hallway", "d"); Room library = new Room("library", libraryName, "d"); kitchen.addExit(Direction.NORTH, hallway); hallway.addExit(Direction.SOUTH, kitchen); hallway.addExit(Direction.NORTH, library); library.addExit(Direction.SOUTH, hallway); if (lockHallway) { kitchen.addExitLock(Direction.NORTH, new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "It's locked.")); } Map rooms = new LinkedHashMap<>(); rooms.put("kitchen", kitchen); rooms.put("hallway", hallway); rooms.put("library", library); World w = new World(rooms, Map.of(), Map.of(), "t", "w"); Player p = new Player(kitchen, 0); p.getVisitedRoomIds().addAll(List.of("kitchen", "hallway", "library")); return new GameContext(w, p, new TestIO()); } private String out(GameContext ctx) { return ((TestIO) ctx.getIo()).allOutput(); } @Test void goToWalksToVisitedRoomById() { GameContext ctx = world(false, "Library"); new GoCommand().execute(ctx, List.of("library")); assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library"); assertThat(out(ctx)).contains("make your way"); } @Test void resolvesByRoomName() { // id "library" but type the display name (lower-cased) — still resolves GameContext ctx = world(false, "Grand Library"); new GoCommand().execute(ctx, List.of("grand", "library")); assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library"); } @Test void unknownPlaceReportsSo() { GameContext ctx = world(false, "Library"); new GoCommand().execute(ctx, List.of("dungeon")); assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen"); assertThat(out(ctx)).contains("don't know any place called 'dungeon'"); } @Test void alreadyThere() { GameContext ctx = world(false, "Library"); new GoCommand().execute(ctx, List.of("kitchen")); assertThat(out(ctx)).contains("already in the Kitchen"); } @Test void noPassablePathReportsSo() { GameContext ctx = world(true, "Library"); // kitchen->hallway locked, flag unset new GoCommand().execute(ctx, List.of("library")); assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen"); assertThat(out(ctx)).contains("can't find a way there"); } @Test void directionalMoveStillWorks() { GameContext ctx = world(false, "Library"); new GoCommand().execute(ctx, List.of("north")); assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("hallway"); } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `mvn -q test -Dtest=GoCommandPathTest` Expected: FAIL — `go to` routing not implemented (e.g. `goToWalksToVisitedRoomById` fails: "library" treated as an unknown direction). - [ ] **Step 3: Rewrite `GoCommand`** Replace `src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java`: ```java package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.Light; import thb.jeanluc.adventure.game.Pathfinder; import thb.jeanluc.adventure.map.MapLayout; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Room; import java.util.ArrayList; import java.util.List; import java.util.Optional; /** * Moves the player. {@code go } steps once to a connected room. * {@code go to } auto-walks the shortest path (BFS) over already-visited * rooms, honouring locked exits and darkness. Usage: {@code go } or * {@code go to }. */ public class GoCommand implements Command { @Override public void execute(GameContext ctx, List args) { if (args.isEmpty()) { ctx.getIo().write("Go where? Try 'go north' or 'go to '."); return; } try { Direction dir = Direction.fromString(args.getFirst()); moveDirection(ctx, dir); } catch (IllegalArgumentException notADirection) { goToRoom(ctx, args); } } /** Single-step move in a compass direction (the original behaviour). */ private void moveDirection(GameContext ctx, Direction dir) { Room here = ctx.getPlayer().getCurrentRoom(); Optional next = here.getExit(dir); if (next.isEmpty()) { ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); return; } ExitLock lock = here.getExitLocks().get(dir); if (lock != null && !Conditions.all(lock.requires(), ctx)) { ctx.getIo().write(lock.blocked()); return; } if (!Light.isLit(ctx, next.get())) { ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source " + "(try lighting your lamp with 'use lamp')."); return; } ctx.getPlayer().setCurrentRoom(next.get()); new LookCommand().execute(ctx, List.of()); } /** Resolve a visited room by id/name and auto-walk the BFS path to it. */ private void goToRoom(GameContext ctx, List args) { String name = String.join(" ", args).toLowerCase(); Room target = resolveVisited(ctx, name); if (target == null) { ctx.getIo().write("You don't know any place called '" + name + "'."); return; } Room start = ctx.getPlayer().getCurrentRoom(); if (target == start) { ctx.getIo().write("You're already in the " + target.getName() + "."); return; } List path = Pathfinder.findPath(ctx, target); if (path.isEmpty()) { ctx.getIo().write("You can't find a way there right now."); return; } List route = new ArrayList<>(); route.add(start); route.addAll(path); for (Room step : path) { ctx.getPlayer().setCurrentRoom(step); ctx.getIo().travelStep(MapLayout.compute( ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step)); } ctx.getIo().write(summary(route)); new LookCommand().execute(ctx, List.of()); } /** Finds a visited room whose id or display name matches {@code name}. */ private Room resolveVisited(GameContext ctx, String name) { for (String id : ctx.getPlayer().getVisitedRoomIds()) { Room r = ctx.getWorld().getRooms().get(id); if (r == null) { continue; } if (id.equals(name) || r.getName().toLowerCase().equals(name)) { return r; } } return null; } /** Names start, destination, and at most one middle waypoint. */ private String summary(List route) { int n = route.size(); String start = route.get(0).getName(); String dest = route.get(n - 1).getName(); if (n >= 3) { String middle = route.get(n / 2).getName(); return "You make your way from the " + start + ", through the " + middle + ", to the " + dest + "."; } return "You make your way from the " + start + " to the " + dest + "."; } @Override public String help() { return "go - move one room (north/south/east/west); " + "or go to to travel to a known room"; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `mvn -q test -Dtest=GoCommandPathTest` Expected: PASS (6 tests). - [ ] **Step 5: Full suite (no regressions to existing go/movement tests)** Run: `mvn -q clean test` Expected: BUILD SUCCESS, all green (existing `GameTest`/command tests that use `go north` still pass). - [ ] **Step 6: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \ src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java git commit -m "feat(command): go to auto-walk (BFS path, animated map, summary)" ``` --- ## Task 4: Docs **Files:** - Modify: `docs/enhancement-ideas.md` - [ ] **Step 1: Mark pathfinding implemented** In `docs/enhancement-ideas.md`, find the "Festgelegte Erweiterungs-Entscheidungen" area where the blockquote notes Pathfinding is *in scope for a later round* (added when main-menu/save-load shipped). Update that note to mark it done, in the existing `> ✅` style: > ✅ umgesetzt (Branch `feature/pathfinding-go-to`): `go to ` per BFS über > besuchte Räume (Locks + Licht beachtet), via `GoCommand` (Richtung vs. Raumziel). > Konsole still + Zusammenfassung (Start/Mitte/Ziel), GUI-Minikarte animiert pro > Schritt (`GameIO.travelStep`, ~300 ms). Trie-Autocomplete weiter zurückgestellt. - [ ] **Step 2: Commit** ```bash git add docs/enhancement-ideas.md git commit -m "docs: mark go to pathfinding implemented" ``` --- ## Final Verification - [ ] `mvn -q clean test` — all tests pass (184 prior + new Pathfinder/GoCommand/travelStep suites). - [ ] Manual GUI smoke (optional): start a game, walk kitchen→hallway→library manually (so they're visited), return to kitchen, then `go to library` — the minimap dot should step through the hallway to the library (~300ms each), then the library room renders with a summary line. - [ ] Manual console smoke (optional): same, confirming silent traversal + the "You make your way ..." line + destination look. - [ ] `git status` clean.