Merge go to <room> pathfinding into develop
BFS auto-walk over visited rooms (respecting locked exits + darkness), routed through GoCommand (go to <room> vs go <direction>). Console: silent traversal + a start/middle/destination summary line. GUI: the minimap animates the player moving room-by-room via a new GameIO.travelStep hook (SwingIO override, ~300ms; console no-op). New stateless Pathfinder BFS in the game package. Trie autocomplete deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,8 +195,11 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
|
|
||||||
## Festgelegte Erweiterungs-Entscheidungen
|
## Festgelegte Erweiterungs-Entscheidungen
|
||||||
|
|
||||||
> **Pathfinding** (BFS `go to <raum>`, Trie-Autocomplete) bleibt für eine
|
> ✅ **Pathfinding** umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>`
|
||||||
> spätere Runde im Scope.
|
> 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 |
|
| Entscheidung | Wert |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
# `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.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Spec: Pathfinding `go to <room>`
|
||||||
|
|
||||||
|
Stand: 2026-06-01. Zweite der verbleibenden Mechaniken (nach `use X on Y`).
|
||||||
|
Setzt den in `enhancement-ideas.md` als Algorithmus-Showcase notierten Punkt um:
|
||||||
|
`go to <raum>` per BFS. Baut auf `GoCommand`, `MapLayout` (BFS-Gitter + Fog of
|
||||||
|
War), `Light`, `Conditions`/`ExitLock` und der `GameIO`-Abstraktion auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Bewegung erfolgt bisher Schritt für Schritt per `go <richtung>`. Ziel: **`go to
|
||||||
|
<raum>`** läuft automatisch per **BFS** über bereits **besuchte** Räume zum Ziel,
|
||||||
|
unter Beachtung derselben Gates wie ein manueller Zug (verschlossene Exits, Licht).
|
||||||
|
In der **GUI** soll die Minikarte die Bewegung **Raum für Raum animiert** zeigen;
|
||||||
|
in der Konsole läuft die Reise still mit einer knappen Zusammenfassung.
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
||||||
|
- **Erreichbarkeit**: nur **besuchte** Räume sind Wegpunkte/Ziele (Fog of War bleibt
|
||||||
|
erhalten; kein Teleport in Unentdecktes).
|
||||||
|
- **Darstellung**: Konsole still + Zusammenfassung + Ziel-`look`; GUI animiert die
|
||||||
|
Minikarte (Map-Push pro Schritt).
|
||||||
|
- **Zusammenfassung**: nennt **Start**, **Ziel** und **höchstens einen** mittleren
|
||||||
|
Wegpunkt (Mittelknoten). Bei 9 Knoten: 1., 5., 9.
|
||||||
|
- **Schrittverzögerung GUI**: ~**300 ms** pro Raum.
|
||||||
|
- **Trie-Autocomplete**: **zurückgestellt** (eigenes späteres Teilprojekt).
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `GoCommand`-Erweiterung: Argument ist Richtung → bestehender Einzelzug; sonst →
|
||||||
|
Raum-Ziel-Pathfinding. Keine neue Verb-Registrierung (Parser entfernt `to`).
|
||||||
|
- `Pathfinder` (`game`): BFS über besuchte Räume mit Passierbarkeits-Prädikat
|
||||||
|
(Exit-Lock + Licht), liefert den Pfad oder leer.
|
||||||
|
- Auto-Walk: Schritt für Schritt `setCurrentRoom` + `GameIO.travelStep(MapView)`;
|
||||||
|
danach Zusammenfassung + `LookCommand`.
|
||||||
|
- Neue `GameIO.travelStep(MapView)`-Default-Methode (No-Op Konsole) + `SwingIO`-
|
||||||
|
Override (Map-Push + ~300 ms Sleep).
|
||||||
|
- Ziel-Auflösung nur unter besuchten Räumen (id, dann name; case-insensitive).
|
||||||
|
- Zusammenfassung mit Start/Mitte/Ziel-Logik.
|
||||||
|
- Tests: `Pathfinder` (BFS, Locks, Licht, besucht-nur, unerreichbar), `GoCommand`
|
||||||
|
(Raum-Ziel, Meldungen, Richtung weiterhin).
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Trie-/Tab-Autocomplete (eigenes Teilprojekt).
|
||||||
|
- Routing durch **unbesuchte** Räume.
|
||||||
|
- Pfad-Neuplanung mitten im Lauf (Zustand ändert sich während eines Zuges nicht).
|
||||||
|
- Gewichtete Kanten / Kosten (alle Kanten gleich; BFS = kürzeste Raumzahl).
|
||||||
|
- Mehr-Wort-Item-/Richtungs-Sonderfälle über die beschriebene Auflösung hinaus.
|
||||||
|
|
||||||
|
## 3. Routing in `GoCommand`
|
||||||
|
|
||||||
|
Der Parser entfernt `to`/`with`/… als Filler, daher liefern `go to library` und
|
||||||
|
`go library` beide `args=[library]`. `execute` verzweigt:
|
||||||
|
|
||||||
|
```java
|
||||||
|
if (args.isEmpty()) { io.write("Go where? Try 'go north' or 'go to <room>'."); return; }
|
||||||
|
String first = args.getFirst();
|
||||||
|
try {
|
||||||
|
Direction dir = Direction.fromString(first);
|
||||||
|
moveDirection(ctx, dir); // bestehender Einzelzug (unverändert ausgelagert)
|
||||||
|
return;
|
||||||
|
} catch (IllegalArgumentException notADirection) {
|
||||||
|
goToRoom(ctx, args); // neuer Raum-Ziel-Pfad
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`moveDirection` = der bisherige Rumpf (Exit prüfen, Lock, Licht, setCurrentRoom,
|
||||||
|
look). `move`/`walk`-Aliase erben beides.
|
||||||
|
|
||||||
|
## 4. Ziel-Auflösung (`goToRoom`)
|
||||||
|
|
||||||
|
- Zielname = `String.join(" ", args).toLowerCase()` (`go to old kitchen` → "old kitchen").
|
||||||
|
- Suche **nur unter besuchten Räumen** (`player.getVisitedRoomIds()` → `world.getRooms().get(id)`):
|
||||||
|
Treffer, wenn `id.equals(name)` **oder** `room.getName().toLowerCase().equals(name)`.
|
||||||
|
- Kein Treffer → `"You don't know any direction or place called '<name>'."` (verrät keine
|
||||||
|
unentdeckten Räume).
|
||||||
|
- Ziel == aktueller Raum → `"You're already in the <Name>."`
|
||||||
|
- sonst → `Pathfinder.findPath`; leer → `"You can't find a way there right now."`;
|
||||||
|
sonst Auto-Walk (§6).
|
||||||
|
|
||||||
|
## 5. `Pathfinder` (game/Pathfinder.java)
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Shortest path (BFS) over visited rooms to target, honouring locks + light.
|
||||||
|
* Returns the steps AFTER the current room, ending at target; empty if none. */
|
||||||
|
public static List<Room> findPath(GameContext ctx, Room target) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
- BFS ab `player.getCurrentRoom()`; Queue + `Map<Room,Room> cameFrom`.
|
||||||
|
- Kante `from --dir--> to` passierbar gdw.:
|
||||||
|
1. `to` ist in `player.getVisitedRoomIds()`,
|
||||||
|
2. Lock: `from.getExitLocks().get(dir)` ist null **oder** `Conditions.all(lock.requires(), ctx)`,
|
||||||
|
3. Licht: `Light.isLit(ctx, to)`.
|
||||||
|
- Bei Erreichen von `target` Pfad via `cameFrom` rekonstruieren (ohne Startraum,
|
||||||
|
inkl. `target`). Unerreichbar → `List.of()`.
|
||||||
|
- Rein über die Domäne + `ctx`; keine IO. Testbar wie `MapLayoutTest`/`QuestEngineTest`.
|
||||||
|
|
||||||
|
Hinweis: Da nur besuchte Räume Kanten liefern und Start immer „besucht" ist, ist
|
||||||
|
die Suche auf die bekannte Karte beschränkt. `target == current` behandelt
|
||||||
|
`goToRoom` vorab (Pathfinder müsste sonst leeren Pfad liefern).
|
||||||
|
|
||||||
|
## 6. Auto-Walk + animierte Minikarte
|
||||||
|
|
||||||
|
```java
|
||||||
|
List<Room> path = Pathfinder.findPath(ctx, target);
|
||||||
|
if (path.isEmpty()) { io.write("You can't find a way there right now."); return; }
|
||||||
|
for (Room step : path) {
|
||||||
|
ctx.getPlayer().setCurrentRoom(step);
|
||||||
|
ctx.getIo().travelStep(MapLayout.compute(
|
||||||
|
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
|
||||||
|
}
|
||||||
|
ctx.getIo().write(summary(routeIncludingStart)); // §7
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
```
|
||||||
|
|
||||||
|
- `setCurrentRoom` markiert Räume als besucht (bereits besucht → idempotent).
|
||||||
|
- `go to` ist **ein** Zug (ein Command) — der Game-Loop erhöht `turn` einmal.
|
||||||
|
|
||||||
|
### `GameIO.travelStep(MapView)`
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Per-step travel frame during auto-walk. Console: no-op (silent). */
|
||||||
|
default void travelStep(MapView view) {
|
||||||
|
// no animation in text mode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SwingIO`-Override:
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public void travelStep(MapView view) {
|
||||||
|
setMap(view); // Map-Panel neu zeichnen (invokeLater)
|
||||||
|
try { Thread.sleep(TRAVEL_STEP_MS); } // ~300 ms, läuft auf dem Worker-Thread
|
||||||
|
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`TRAVEL_STEP_MS = 300`. Konsole bleibt still und ohne Pause (Default No-Op).
|
||||||
|
Der reguläre `publishHud` am Zugende setzt den finalen Map-/HUD-Stand.
|
||||||
|
|
||||||
|
## 7. Zusammenfassungs-Zeile (§ Wegpunkte)
|
||||||
|
|
||||||
|
Über die volle Route `route = [current] + path` (n = `route.size()`, Index 0..n-1):
|
||||||
|
- **Start** = `route[0]`, **Ziel** = `route[n-1]` immer genannt.
|
||||||
|
- **Mitte** = `route[n/2]` nur wenn `n >= 3` und der Index weder 0 noch n-1 ist
|
||||||
|
(also höchstens **ein** Zwischenpunkt). Bei n=9 → Index 4 (der 5. Knoten).
|
||||||
|
- Rendering:
|
||||||
|
- n == 2: `"You make your way from the <Start> to the <Ziel>."`
|
||||||
|
- n >= 3: `"You make your way from the <Start>, through the <Mitte>, to the <Ziel>."`
|
||||||
|
|
||||||
|
(Artikel „the" bewusst einfach gehalten; reine Flavor-Zeile, leicht anpassbar.)
|
||||||
|
|
||||||
|
## 8. Fehler-/Randfälle (Meldungen)
|
||||||
|
|
||||||
|
| Fall | Meldung |
|
||||||
|
|---|---|
|
||||||
|
| `go` ohne Argument | "Go where? Try 'go north' or 'go to <room>'." |
|
||||||
|
| Richtung ohne Exit | (bestehend) "You can't go <dir> from here." |
|
||||||
|
| Richtung gesperrt / dunkel | (bestehend) Lock-Text / Dunkelheits-Hinweis |
|
||||||
|
| Zielname unbekannt (unter besuchten) | "You don't know any direction or place called '<name>'." |
|
||||||
|
| Ziel == aktueller Raum | "You're already in the <Name>." |
|
||||||
|
| Ziel besucht, aber kein passierbarer Pfad | "You can't find a way there right now." |
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
- **`PathfinderTest`** (Welt inline, wie `MapLayoutTest`):
|
||||||
|
- kürzester Pfad über besuchte Räume (Länge + Knoten korrekt).
|
||||||
|
- unbesuchter Zwischenraum wird **nicht** als Wegpunkt genutzt (kein Pfad bzw.
|
||||||
|
Umweg nur über Besuchtes).
|
||||||
|
- kein Pfad durch einen Exit mit nicht erfüllter `ExitLock`-Bedingung; Pfad
|
||||||
|
erscheint, wenn die Flag gesetzt ist.
|
||||||
|
- kein Pfad in einen dunklen Raum ohne Licht; mit getragenem Licht erreichbar.
|
||||||
|
- unerreichbares Ziel → leere Liste.
|
||||||
|
- **`GoCommand`-Tests** (Raum-Ziel):
|
||||||
|
- `go to <name>` läuft zum Ziel (currentRoom aktualisiert, Ziel-`look` erfolgt).
|
||||||
|
- Auflösung per id **und** per name (case-insensitive).
|
||||||
|
- unbekannter Name / Ziel==aktuell / kein Pfad → jeweilige Meldung.
|
||||||
|
- `go north` (Richtung) funktioniert weiterhin (Regressionsschutz).
|
||||||
|
- **`travelStep`**: Default ist No-Op (Tests nutzen `TestIO`; keine Sleeps, keine
|
||||||
|
GUI). GUI-Animation wird manuell verifiziert (Konvention: keine GUI-Unit-Tests).
|
||||||
|
|
||||||
|
## 10. Architektur-Werte / Risiken
|
||||||
|
|
||||||
|
- **Gemeinsamer Loop bleibt**: nur eine neue `GameIO`-Default-Methode; Pacing/
|
||||||
|
Animation lebt im Renderer (`SwingIO`), Konsole No-Op. Keine Divergenz der Logik.
|
||||||
|
- **Konsistenz mit manuellem Zug**: Pathfinder nutzt dieselben Gates (Lock, Licht)
|
||||||
|
wie `GoCommand` — kein Auto-Walk durch etwas, das man manuell nicht passieren darf.
|
||||||
|
- **Fog of War**: nur besuchte Räume → kein Informationsleck über Unentdecktes.
|
||||||
|
- **Algorithmus-Showcase**: klassisches BFS (Queue + `cameFrom`) mit
|
||||||
|
Standard-Collections (entspricht der Projekt-Entscheidung „keine eigenen
|
||||||
|
`uebung`-Strukturen"). `MapLayout` macht bereits BFS — `Pathfinder` ist die
|
||||||
|
fokussierte Wegfindungs-Variante (eigene Verantwortlichkeit, eigene Tests).
|
||||||
|
- **GUI-Threading**: `travelStep` schläft auf dem Worker-Thread (wie `readLine`
|
||||||
|
blockiert), `setMap` zeichnet via EDT — kein Deadlock; EDT bleibt responsiv.
|
||||||
|
- Risiko: lange Pfade × 300 ms könnten sich zäh anfühlen; bewusst akzeptiert (kurze
|
||||||
|
Karte). Später ggf. Settings-gesteuert.
|
||||||
@@ -4,38 +4,48 @@ import thb.jeanluc.adventure.command.Command;
|
|||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.Light;
|
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.Direction;
|
||||||
import thb.jeanluc.adventure.model.ExitLock;
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves the player into the room reachable in a given direction.
|
* Moves the player. {@code go <direction>} steps once to a connected room.
|
||||||
* Usage: {@code go <direction>}.
|
* {@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>}. The parser strips the filler word "to", so "go to
|
||||||
|
* <room>" and "go <room>" are equivalent.
|
||||||
*/
|
*/
|
||||||
public class GoCommand implements Command {
|
public class GoCommand implements Command {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute(GameContext ctx, List<String> args) {
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
if (args.isEmpty()) {
|
if (args.isEmpty()) {
|
||||||
ctx.getIo().write("Go where? Try 'go north'.");
|
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Direction dir;
|
|
||||||
try {
|
try {
|
||||||
dir = Direction.fromString(args.getFirst());
|
Direction dir = Direction.fromString(args.getFirst());
|
||||||
} catch (IllegalArgumentException e) {
|
moveDirection(ctx, dir);
|
||||||
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
|
} catch (IllegalArgumentException notADirection) {
|
||||||
return;
|
goToRoom(ctx, args);
|
||||||
}
|
}
|
||||||
Optional<Room> 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<Room> next = here.getExit(dir);
|
||||||
if (next.isEmpty()) {
|
if (next.isEmpty()) {
|
||||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir);
|
ExitLock lock = here.getExitLocks().get(dir);
|
||||||
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
ctx.getIo().write(lock.blocked());
|
ctx.getIo().write(lock.blocked());
|
||||||
return;
|
return;
|
||||||
@@ -49,8 +59,70 @@ public class GoCommand implements Command {
|
|||||||
new LookCommand().execute(ctx, List.of());
|
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 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<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) {
|
||||||
|
// 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<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
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "go <direction> - move to the connected room (north/south/east/west)";
|
return "go <direction> - move one room (north/south/east/west); "
|
||||||
|
+ "or go to <room> to travel to a known room";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,17 @@ public interface GameIO {
|
|||||||
// no-op
|
// 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). */
|
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
|
||||||
default void showMap(MapView view) {
|
default void showMap(MapView view) {
|
||||||
print(AsciiMap.render(view, true));
|
print(AsciiMap.render(view, true));
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ public class SwingIO implements GameIO {
|
|||||||
/** Unscaled size for the HUD and side-panel font, in points. */
|
/** Unscaled size for the HUD and side-panel font, in points. */
|
||||||
private static final float SMALL_SIZE = 12.5f;
|
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. */
|
/** 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 double SIDE_RATIO = 0.28;
|
||||||
private static final int SIDE_MIN = 180;
|
private static final int SIDE_MIN = 180;
|
||||||
@@ -335,6 +338,16 @@ public class SwingIO implements GameIO {
|
|||||||
map.show(view);
|
map.show(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void travelStep(MapView view) {
|
||||||
|
setMap(view);
|
||||||
|
try {
|
||||||
|
Thread.sleep(TRAVEL_STEP_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setQuests(QuestView view) {
|
public void setQuests(QuestView view) {
|
||||||
quests.show(view);
|
quests.show(view);
|
||||||
|
|||||||
@@ -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<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");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user