diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 94e6b11..cbcbb95 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -195,8 +195,11 @@ vorerst puzzle-fokussiert ohne Survival-Druck. ## Festgelegte Erweiterungs-Entscheidungen -> **Pathfinding** (BFS `go to `, Trie-Autocomplete) bleibt für eine -> spätere Runde im Scope. +> ✅ **Pathfinding** 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** weiterhin für eine spätere Runde zurückgestellt. | Entscheidung | Wert | |---|---| diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java index c98af8b..ecea4df 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java @@ -4,38 +4,48 @@ 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 into the room reachable in a given direction. - * Usage: {@code go }. + * 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 }. The parser strips the filler word "to", so "go to + * <room>" and "go <room>" are equivalent. */ public class GoCommand implements Command { @Override public void execute(GameContext ctx, List args) { if (args.isEmpty()) { - ctx.getIo().write("Go where? Try 'go north'."); + ctx.getIo().write("Go where? Try 'go north' or 'go to '."); return; } - Direction dir; try { - dir = Direction.fromString(args.getFirst()); - } catch (IllegalArgumentException e) { - ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is."); - return; + Direction dir = Direction.fromString(args.getFirst()); + moveDirection(ctx, dir); + } catch (IllegalArgumentException notADirection) { + goToRoom(ctx, args); } - Optional next = ctx.getPlayer().getCurrentRoom().getExit(dir); + } + + /** 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 = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir); + ExitLock lock = here.getExitLocks().get(dir); if (lock != null && !Conditions.all(lock.requires(), ctx)) { ctx.getIo().write(lock.blocked()); return; @@ -49,8 +59,70 @@ public class GoCommand implements Command { 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 direction or 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) { + // the command test harness (CommandTestSupport) uses a null world; production always has one + if (ctx.getWorld() == null) { + return null; + } + for (String id : ctx.getPlayer().getVisitedRoomIds()) { + Room r = ctx.getWorld().getRooms().get(id); + if (r == null) { + continue; + } + if (id.equalsIgnoreCase(name) || r.getName().equalsIgnoreCase(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 to the connected room (north/south/east/west)"; + return "go - move one room (north/south/east/west); " + + "or go to to travel to a known room"; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Pathfinder.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Pathfinder.java new file mode 100644 index 0000000..981a7a0 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Pathfinder.java @@ -0,0 +1,76 @@ +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; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index 9ca91b1..ccbdf88 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -44,6 +44,17 @@ public interface GameIO { // no-op } + /** + * 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 + } + /** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */ default void showMap(MapView view) { print(AsciiMap.render(view, true)); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index 8123357..8501488 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -66,6 +66,9 @@ public class SwingIO implements GameIO { /** Unscaled size for the HUD and side-panel font, in points. */ private static final float SMALL_SIZE = 12.5f; + /** Delay between rooms while animating a go-to auto-walk, in milliseconds. */ + private static final long TRAVEL_STEP_MS = 300L; + /** Side-panel width as a fraction of the window width, clamped by the bounds below. */ private static final double SIDE_RATIO = 0.28; private static final int SIDE_MIN = 180; @@ -335,6 +338,16 @@ public class SwingIO implements GameIO { map.show(view); } + @Override + public void travelStep(MapView view) { + setMap(view); + try { + Thread.sleep(TRAVEL_STEP_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + @Override public void setQuests(QuestView view) { quests.show(view); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java new file mode 100644 index 0000000..b980b49 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java @@ -0,0 +1,103 @@ +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"); + assertThat(out(ctx)).contains("through the Hallway"); + } + + @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 direction or 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"); + } + + @Test + void adjacentWalkUsesShortSummary() { + GameContext ctx = world(false, "Library"); + new GoCommand().execute(ctx, List.of("hallway")); + assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("hallway"); + assertThat(out(ctx)).contains("make your way from the Kitchen to the Hallway"); + assertThat(out(ctx)).doesNotContain("through"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java new file mode 100644 index 0000000..2eaa376 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java @@ -0,0 +1,88 @@ +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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java new file mode 100644 index 0000000..e3cb414 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java @@ -0,0 +1,19 @@ +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(); + } +}