# `go to <room>` 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 <room>` — 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<Room> all, Set<String> visited) {
        Map<String, Room> 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<Room> findPath(GameContext ctx, Room target) {
        Room start = ctx.getPlayer().getCurrentRoom();
        Set<String> visited = ctx.getPlayer().getVisitedRoomIds();

        Queue<Room> queue = new ArrayDeque<>();
        Map<Room, Room> 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<Direction, Room> 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<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
        LinkedList<Room> 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<String, Room> 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 <direction>} steps once to a connected room.
 * {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
 * rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
 * {@code go to <room>}.
 */
public class GoCommand implements Command {

    @Override
    public void execute(GameContext ctx, List<String> args) {
        if (args.isEmpty()) {
            ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
            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<Room> 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<String> 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<Room> path = Pathfinder.findPath(ctx, target);
        if (path.isEmpty()) {
            ctx.getIo().write("You can't find a way there right now.");
            return;
        }
        List<Room> 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<Room> 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 <direction>  -  move one room (north/south/east/west); "
                + "or  go to <room>  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 <room> 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 <raum>` 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 <room> 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.
