Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-06-01-pathfinding-go-to.md
Jean-Luc Makiola 7b60230f1e docs: implementation plan for go to <room> pathfinding
4 TDD tasks: GameIO.travelStep hook (+SwingIO animation), Pathfinder BFS over
visited rooms (locks+light), GoCommand routing/resolve/walk/summary, docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:02:11 +02:00

22 KiB

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:

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:

    /**
     * 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):
    /** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
    private static final long TRAVEL_STEP_MS = 300L;
  1. Add the override (place it near setMap):
    @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
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:

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:

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
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:

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:

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
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
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.