Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-05-31-map.md
2026-05-31 20:57:00 +02:00

1140 lines
37 KiB
Markdown

# Map / Mini-Map 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:** A fog-of-war map of the manor in both frontends — GUI draws it graphically in the side panel, console mirrors it as ASCII via a `map` command — derived from the existing directional exit graph.
**Architecture:** A `MapLayout` service BFS-walks `Room.exits` to assign grid coordinates and applies fog-of-war (visited rooms + their unexplored neighbours), producing a frontend-agnostic `MapView`. `SwingIO` renders it with Graphics2D (`MapPanel`); `ConsoleIO` renders ASCII (`AsciiMap`). `Player` gains visited-room tracking; `Game` pushes the map each turn via `GameIO.setMap`.
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Swing (Graphics2D).
Spec: [docs/superpowers/specs/2026-05-31-map-design.md](../specs/2026-05-31-map-design.md)
---
## File Structure
**Create:**
- `src/main/java/thb/jeanluc/adventure/io/text/CellState.java`
- `src/main/java/thb/jeanluc/adventure/io/text/RoomCell.java`
- `src/main/java/thb/jeanluc/adventure/io/text/Connection.java`
- `src/main/java/thb/jeanluc/adventure/io/text/MapView.java`
- `src/main/java/thb/jeanluc/adventure/io/text/AsciiMap.java`
- `src/main/java/thb/jeanluc/adventure/map/MapLayout.java`
- `src/main/java/thb/jeanluc/adventure/io/MapPanel.java`
- `src/main/java/thb/jeanluc/adventure/command/impl/MapCommand.java`
- Tests: `MapLayoutTest`, `AsciiMapTest`, `MapCommandTest`, plus additions to `PlayerTest`.
**Modify:**
- `model/Player.java` — visited-room tracking
- `io/GameIO.java``setMap`/`showMap` defaults
- `io/ConsoleIO.java` — override `showMap`
- `io/SwingIO.java` — side panel becomes `MapPanel`; override `setMap`/`showMap`; drop exits text
- `game/Game.java` — push map each turn
- `App.java` — register `MapCommand`
---
## Task 1: Visited-room tracking on `Player`
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/model/Player.java`
- Test: `src/test/java/thb/jeanluc/adventure/model/PlayerTest.java`
- [ ] **Step 1: Add the failing tests** (append to `PlayerTest`)
```java
@Test
void startRoomIsVisitedFromTheStart() {
Room start = new Room("kitchen", "Kitchen", "desc");
Player p = new Player(start, 0);
org.assertj.core.api.Assertions.assertThat(p.getVisitedRoomIds()).containsExactly("kitchen");
}
@Test
void movingMarksTheNewRoomVisited() {
Room start = new Room("kitchen", "Kitchen", "desc");
Room hall = new Room("hallway", "Hallway", "desc");
Player p = new Player(start, 0);
p.setCurrentRoom(hall);
org.assertj.core.api.Assertions.assertThat(p.getVisitedRoomIds())
.containsExactly("kitchen", "hallway");
}
```
(If the `Room(String,String,String)` constructor differs, match the one the existing `PlayerTest` already uses.)
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=PlayerTest`
Expected: FAIL — `getVisitedRoomIds` missing / visited not tracked.
- [ ] **Step 3: Implement tracking in `Player.java`**
Add the import:
```java
import java.util.LinkedHashSet;
import java.util.Set;
```
Add the field (with the other fields; the class is already `@Getter`):
```java
/** Ids of rooms the player has entered, in first-visit order (for the map). */
private final Set<String> visitedRoomIds = new LinkedHashSet<>();
```
Remove `@Setter` from `currentRoom` and replace the generated setter with an explicit one that also records the visit. Change:
```java
/** Room the player is currently standing in. */
@Setter
private Room currentRoom;
```
to:
```java
/** Room the player is currently standing in. */
private Room currentRoom;
```
and add this method (anywhere among the methods):
```java
/**
* Moves the player into a room and records it as visited.
*
* @param room the room now occupied; must not be null
*/
public void setCurrentRoom(Room room) {
this.currentRoom = room;
this.visitedRoomIds.add(room.getId());
}
```
In the constructor, after `this.currentRoom = startRoom;`, add:
```java
this.visitedRoomIds.add(startRoom.getId());
```
- [ ] **Step 4: Run to verify pass**
Run: `mvn -q test -Dtest=PlayerTest`
Expected: PASS.
- [ ] **Step 5: Run full suite** (the `@Setter` removal could affect other callers)
Run: `mvn -q test`
Expected: PASS — `GoCommand` uses `setCurrentRoom`, now satisfied by the explicit method.
- [ ] **Step 6: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/model/Player.java \
src/test/java/thb/jeanluc/adventure/model/PlayerTest.java
git commit -m "feat(model): track visited rooms on Player for the map"
```
---
## Task 2: `MapView` model
**Files:**
- Create: `CellState.java`, `RoomCell.java`, `Connection.java`, `MapView.java` (all in `io/text/`)
- Test: `src/test/java/thb/jeanluc/adventure/io/text/MapViewTest.java`
- [ ] **Step 1: Write the failing test**
```java
package thb.jeanluc.adventure.io.text;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class MapViewTest {
@Test
void mapViewIsImmutableAndHoldsCellsAndConnections() {
RoomCell a = new RoomCell("kitchen", "Kitchen", 0, 0, CellState.CURRENT);
RoomCell b = new RoomCell("hallway", "Hallway", 0, -1, CellState.VISITED);
MapView v = new MapView(List.of(a, b), List.of(new Connection(a, b)));
assertThat(v.cells()).hasSize(2);
assertThat(v.connections()).hasSize(1);
assertThat(v.connections().getFirst().from().id()).isEqualTo("kitchen");
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=MapViewTest`
Expected: FAIL — types don't exist.
- [ ] **Step 3: Create the four files**
`CellState.java`:
```java
package thb.jeanluc.adventure.io.text;
/** Fog-of-war state of a room on the map. */
public enum CellState { CURRENT, VISITED, KNOWN }
```
`RoomCell.java`:
```java
package thb.jeanluc.adventure.io.text;
/** A room placed on the map grid at integer coords, with its fog-of-war state. */
public record RoomCell(String id, String name, int x, int y, CellState state) {}
```
`Connection.java`:
```java
package thb.jeanluc.adventure.io.text;
/** A corridor between two placed rooms. */
public record Connection(RoomCell from, RoomCell to) {}
```
`MapView.java`:
```java
package thb.jeanluc.adventure.io.text;
import java.util.List;
/** Frontend-agnostic snapshot of the visible map: placed cells + connections. */
public record MapView(List<RoomCell> cells, List<Connection> connections) {
public MapView {
cells = List.copyOf(cells);
connections = List.copyOf(connections);
}
}
```
- [ ] **Step 4: Run to verify pass**
Run: `mvn -q test -Dtest=MapViewTest`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/io/text/CellState.java \
src/main/java/thb/jeanluc/adventure/io/text/RoomCell.java \
src/main/java/thb/jeanluc/adventure/io/text/Connection.java \
src/main/java/thb/jeanluc/adventure/io/text/MapView.java \
src/test/java/thb/jeanluc/adventure/io/text/MapViewTest.java
git commit -m "feat(io): MapView model (CellState, RoomCell, Connection, MapView)"
```
---
## Task 3: `MapLayout` — BFS layout + fog of war
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/map/MapLayout.java`
- Test: `src/test/java/thb/jeanluc/adventure/map/MapLayoutTest.java`
- [ ] **Step 1: Write the failing test**
```java
package thb.jeanluc.adventure.map;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.text.CellState;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.RoomCell;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class MapLayoutTest {
private World worldOf(Room... rooms) {
Map<String, Room> byId = new java.util.LinkedHashMap<>();
for (Room r : rooms) {
byId.put(r.getId(), r);
}
return new World(byId, Map.of(), Map.of(), "t", "w");
}
private Optional<RoomCell> cell(MapView v, String id) {
return v.cells().stream().filter(c -> c.id().equals(id)).findFirst();
}
@Test
void placesNeighbourByDirectionAndMarksCurrent() {
Room kitchen = new Room("kitchen", "Kitchen", "d");
Room hallway = new Room("hallway", "Hallway", "d");
kitchen.addExit(Direction.NORTH, hallway);
hallway.addExit(Direction.SOUTH, kitchen);
World w = worldOf(kitchen, hallway);
Set<String> visited = new LinkedHashSet<>(Set.of("kitchen", "hallway"));
MapView v = MapLayout.compute(w, new LinkedHashSet<>(java.util.List.of("kitchen", "hallway")), hallway);
RoomCell k = cell(v, "kitchen").orElseThrow();
RoomCell h = cell(v, "hallway").orElseThrow();
// hallway is north of kitchen -> smaller y
assertThat(h.y()).isLessThan(k.y());
assertThat(h.state()).isEqualTo(CellState.CURRENT);
assertThat(k.state()).isEqualTo(CellState.VISITED);
}
@Test
void unexploredNeighbourIsKnownStub() {
Room kitchen = new Room("kitchen", "Kitchen", "d");
Room cellar = new Room("cellar", "Cellar", "d");
kitchen.addExit(Direction.EAST, cellar);
cellar.addExit(Direction.WEST, kitchen);
World w = worldOf(kitchen, cellar);
MapView v = MapLayout.compute(w, new LinkedHashSet<>(java.util.List.of("kitchen")), kitchen);
RoomCell c = cell(v, "cellar").orElseThrow();
assertThat(c.state()).isEqualTo(CellState.KNOWN);
assertThat(c.name()).isEqualTo("?");
assertThat(v.connections()).hasSize(1);
}
@Test
void coordsAreNormalisedToNonNegative() {
Room kitchen = new Room("kitchen", "Kitchen", "d");
Room west = new Room("west", "West", "d");
kitchen.addExit(Direction.WEST, west);
west.addExit(Direction.EAST, kitchen);
World w = worldOf(kitchen, west);
MapView v = MapLayout.compute(w, new LinkedHashSet<>(java.util.List.of("kitchen", "west")), kitchen);
assertThat(v.cells()).allSatisfy(c -> {
assertThat(c.x()).isGreaterThanOrEqualTo(0);
assertThat(c.y()).isGreaterThanOrEqualTo(0);
});
}
@Test
void emptyVisitedYieldsEmptyMap() {
World w = worldOf(new Room("kitchen", "Kitchen", "d"));
MapView v = MapLayout.compute(w, new LinkedHashSet<>(), null);
assertThat(v.cells()).isEmpty();
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=MapLayoutTest`
Expected: FAIL — `MapLayout` does not exist.
- [ ] **Step 3: Implement `MapLayout.java`**
```java
package thb.jeanluc.adventure.map;
import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.io.text.CellState;
import thb.jeanluc.adventure.io.text.Connection;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.RoomCell;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.World;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Computes a {@link MapView} from the room graph: BFS assigns grid coordinates
* from exit directions, then fog-of-war keeps only visited rooms and their
* directly-reachable (but unentered) neighbours.
*/
@Slf4j
public final class MapLayout {
private MapLayout() {
}
public static MapView compute(World world, Set<String> visited, Room current) {
if (visited.isEmpty()) {
return new MapView(List.of(), List.of());
}
String anchorId = visited.iterator().next();
Room anchor = world.getRooms().get(anchorId);
if (anchor == null) {
return new MapView(List.of(), List.of());
}
Map<String, int[]> coords = bfs(anchor);
// Determine which rooms are placed: visited (with coords) + their unentered neighbours.
Map<String, CellState> placed = new LinkedHashMap<>();
for (String id : visited) {
if (coords.containsKey(id)) {
placed.put(id, id.equals(current.getId()) ? CellState.CURRENT : CellState.VISITED);
}
}
for (String id : visited) {
Room r = world.getRooms().get(id);
if (r == null) {
continue;
}
for (Room nb : r.getExits().values()) {
if (!placed.containsKey(nb.getId()) && coords.containsKey(nb.getId())) {
placed.put(nb.getId(), CellState.KNOWN);
}
}
}
// Normalise coordinates to non-negative.
int minX = Integer.MAX_VALUE;
int minY = Integer.MAX_VALUE;
for (String id : placed.keySet()) {
int[] c = coords.get(id);
minX = Math.min(minX, c[0]);
minY = Math.min(minY, c[1]);
}
Map<String, RoomCell> cells = new LinkedHashMap<>();
for (Map.Entry<String, CellState> e : placed.entrySet()) {
int[] c = coords.get(e.getKey());
String name = e.getValue() == CellState.KNOWN ? "?" : world.getRooms().get(e.getKey()).getName();
cells.put(e.getKey(), new RoomCell(e.getKey(), name, c[0] - minX, c[1] - minY, e.getValue()));
}
// Connections: from each visited room to any placed neighbour, de-duplicated.
List<Connection> connections = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (String id : visited) {
Room r = world.getRooms().get(id);
RoomCell from = cells.get(id);
if (r == null || from == null) {
continue;
}
for (Room nb : r.getExits().values()) {
RoomCell to = cells.get(nb.getId());
if (to == null) {
continue;
}
String key = id.compareTo(nb.getId()) < 0 ? id + "|" + nb.getId() : nb.getId() + "|" + id;
if (seen.add(key)) {
connections.add(new Connection(from, to));
}
}
}
return new MapView(List.copyOf(cells.values()), connections);
}
private static Map<String, int[]> bfs(Room anchor) {
Map<String, int[]> coords = new HashMap<>();
Deque<Room> queue = new ArrayDeque<>();
coords.put(anchor.getId(), new int[]{0, 0});
queue.add(anchor);
while (!queue.isEmpty()) {
Room r = queue.poll();
int[] c = coords.get(r.getId());
for (Map.Entry<Direction, Room> e : r.getExits().entrySet()) {
Room nb = e.getValue();
int[] d = delta(e.getKey());
int[] nc = {c[0] + d[0], c[1] + d[1]};
int[] existing = coords.get(nb.getId());
if (existing == null) {
coords.put(nb.getId(), nc);
queue.add(nb);
} else if (existing[0] != nc[0] || existing[1] != nc[1]) {
log.warn("Map layout collision at room '{}' ({} vs {},{}); keeping first placement",
nb.getId(), java.util.Arrays.toString(existing), nc[0], nc[1]);
}
}
}
return coords;
}
private static int[] delta(Direction d) {
return switch (d) {
case NORTH -> new int[]{0, -1};
case SOUTH -> new int[]{0, 1};
case EAST -> new int[]{1, 0};
case WEST -> new int[]{-1, 0};
};
}
}
```
- [ ] **Step 4: Run to verify pass**
Run: `mvn -q test -Dtest=MapLayoutTest`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/map/MapLayout.java \
src/test/java/thb/jeanluc/adventure/map/MapLayoutTest.java
git commit -m "feat(map): MapLayout BFS grid layout with fog of war"
```
---
## Task 4: `AsciiMap` — console renderer
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/io/text/AsciiMap.java`
- Test: `src/test/java/thb/jeanluc/adventure/io/text/AsciiMapTest.java`
- [ ] **Step 1: Write the failing test**
```java
package thb.jeanluc.adventure.io.text;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AsciiMapTest {
@Test
void rendersVisitedNameCurrentAndKnownStub() {
RoomCell kitchen = new RoomCell("kitchen", "Kitchen", 0, 1, CellState.CURRENT);
RoomCell hallway = new RoomCell("hallway", "Hallway", 0, 0, CellState.VISITED);
RoomCell cellar = new RoomCell("cellar", "?", 1, 1, CellState.KNOWN);
MapView v = new MapView(List.of(kitchen, hallway, cellar),
List.of(new Connection(hallway, kitchen), new Connection(kitchen, cellar)));
String out = AsciiMap.render(v, true).plainText();
assertThat(out).contains("Kitchen");
assertThat(out).contains("Hallway");
assertThat(out).contains("?");
}
@Test
void emptyMapRendersPlaceholder() {
String out = AsciiMap.render(new MapView(List.of(), List.of()), true).plainText();
assertThat(out).containsIgnoringCase("no map");
}
@Test
void asciiModeUsesPlusFrames() {
RoomCell only = new RoomCell("kitchen", "Kitchen", 0, 0, CellState.CURRENT);
String out = AsciiMap.render(new MapView(List.of(only), List.of()), false).plainText();
assertThat(out).contains("+").doesNotContain("");
}
}
```
- [ ] **Step 2: Run to verify failure**
Run: `mvn -q test -Dtest=AsciiMapTest`
Expected: FAIL — `AsciiMap` does not exist.
- [ ] **Step 3: Implement `AsciiMap.java`**
```java
package thb.jeanluc.adventure.io.text;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/**
* Renders a {@link MapView} as a styled ASCII grid: boxed rooms connected by
* corridor lines. Box-drawing uses Unicode or pure ASCII depending on {@code unicode}.
*/
public final class AsciiMap {
private static final int INNER = 7; // room name field width
private static final int GAP = 3; // horizontal corridor width
private AsciiMap() {
}
public static StyledText render(MapView view, boolean unicode) {
if (view.cells().isEmpty()) {
return StyledText.of("(no map yet)");
}
int maxX = 0;
int maxY = 0;
Map<Long, RoomCell> at = new HashMap<>();
for (RoomCell c : view.cells()) {
maxX = Math.max(maxX, c.x());
maxY = Math.max(maxY, c.y());
at.put(key(c.x(), c.y()), c);
}
Set<Long> hEdge = new HashSet<>();
Set<Long> vEdge = new HashSet<>();
for (Connection cn : view.connections()) {
RoomCell a = cn.from();
RoomCell b = cn.to();
if (a.y() == b.y()) {
hEdge.add(key(Math.min(a.x(), b.x()), a.y()));
} else if (a.x() == b.x()) {
vEdge.add(key(a.x(), Math.min(a.y(), b.y())));
}
}
String tl = unicode ? "" : "+";
String tr = unicode ? "" : "+";
String bl = unicode ? "" : "+";
String br = unicode ? "" : "+";
String hz = unicode ? "" : "-";
String vt = unicode ? "" : "|";
StyledText.Builder b = StyledText.builder();
for (int gy = 0; gy <= maxY; gy++) {
for (int line = 0; line < 3; line++) {
for (int gx = 0; gx <= maxX; gx++) {
RoomCell c = at.get(key(gx, gy));
if (c == null) {
b.plain(" ".repeat(INNER + 2));
} else if (line == 0) {
styled(b, tl + hz.repeat(INNER) + tr, c.state());
} else if (line == 2) {
styled(b, bl + hz.repeat(INNER) + br, c.state());
} else {
styled(b, vt + center(label(c), INNER) + vt, c.state());
}
if (gx < maxX) {
if (line == 1 && hEdge.contains(key(gx, gy))) {
b.dim(hz.repeat(GAP));
} else {
b.plain(" ".repeat(GAP));
}
}
}
b.plain("\n");
}
if (gy < maxY) {
for (int gx = 0; gx <= maxX; gx++) {
if (vEdge.contains(key(gx, gy))) {
b.plain(" ".repeat(4)).dim(vt).plain(" ".repeat(4));
} else {
b.plain(" ".repeat(INNER + 2));
}
if (gx < maxX) {
b.plain(" ".repeat(GAP));
}
}
b.plain("\n");
}
}
return b.build();
}
private static void styled(StyledText.Builder b, String s, CellState state) {
switch (state) {
case CURRENT -> b.heading(s);
case KNOWN -> b.dim(s);
default -> b.plain(s);
}
}
private static String label(RoomCell c) {
String n = c.name();
if (n.length() > INNER) {
n = n.substring(0, INNER);
}
return n;
}
private static String center(String s, int width) {
int total = width - s.length();
int left = total / 2;
int right = total - left;
return " ".repeat(left) + s + " ".repeat(right);
}
private static long key(int x, int y) {
return (((long) x) << 32) ^ (y & 0xffffffffL);
}
}
```
- [ ] **Step 4: Run to verify pass**
Run: `mvn -q test -Dtest=AsciiMapTest`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/io/text/AsciiMap.java \
src/test/java/thb/jeanluc/adventure/io/text/AsciiMapTest.java
git commit -m "feat(io): AsciiMap console renderer for MapView"
```
---
## Task 5: `GameIO` map methods, `ConsoleIO` override, `MapCommand`
**Files:**
- Modify: `io/GameIO.java`, `io/ConsoleIO.java`, `App.java`
- Create: `command/impl/MapCommand.java`
- Test: `src/test/java/thb/jeanluc/adventure/command/impl/MapCommandTest.java`
- [ ] **Step 1: Add map methods to `GameIO.java`**
Add imports:
```java
import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.MapView;
```
Add inside the interface (after `setHud`):
```java
/** Per-turn push of the current map. GUI repaints its panel; console ignores. */
default void setMap(MapView view) {
// no-op
}
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
default void showMap(MapView view) {
print(AsciiMap.render(view, true));
}
```
- [ ] **Step 2: Override `showMap` in `ConsoleIO.java`**
Add imports:
```java
import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.MapView;
```
Add method:
```java
@Override
public void showMap(MapView view) {
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
}
```
- [ ] **Step 3: Write the failing `MapCommandTest`**
```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.Direction;
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 MapCommandTest {
@Test
void map_showsVisitedRoomAndKnownNeighbour() {
Room kitchen = new Room("kitchen", "Kitchen", "d");
Room hallway = new Room("hallway", "Hallway", "d");
kitchen.addExit(Direction.NORTH, hallway);
hallway.addExit(Direction.SOUTH, kitchen);
Map<String, Room> rooms = new LinkedHashMap<>();
rooms.put("kitchen", kitchen);
rooms.put("hallway", hallway);
World world = new World(rooms, Map.of(), Map.of(), "t", "w");
Player player = new Player(kitchen, 0); // visited = {kitchen}; hallway is a KNOWN stub
TestIO io = new TestIO();
GameContext ctx = new GameContext(world, player, io);
new MapCommand().execute(ctx, List.of());
String out = io.allOutput();
assertThat(out).contains("Kitchen"); // current room name appears on the map
assertThat(out).contains("?"); // unexplored neighbour stub
}
}
```
(Builds its own world: `CommandTestSupport`'s `GameContext` has a `null` world, which `MapCommand` needs.)
- [ ] **Step 4: Run to verify failure**
Run: `mvn -q test -Dtest=MapCommandTest`
Expected: FAIL — `MapCommand` does not exist.
- [ ] **Step 5: Implement `MapCommand.java`**
```java
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
import java.util.List;
/** Shows the fog-of-war map of explored rooms. Usage: {@code map}. */
public class MapCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
MapView view = MapLayout.compute(
ctx.getWorld(),
ctx.getPlayer().getVisitedRoomIds(),
ctx.getPlayer().getCurrentRoom());
ctx.getIo().showMap(view);
}
@Override
public String help() {
return "map - show the map of explored rooms";
}
}
```
- [ ] **Step 6: Register the command in `App.java`**
After the `ExamineCommand` registration line, add:
```java
registry.register(new MapCommand(), "map", "m");
```
Add the import:
```java
import thb.jeanluc.adventure.command.impl.MapCommand;
```
- [ ] **Step 7: Run tests**
Run: `mvn -q test -Dtest=MapCommandTest`
Expected: PASS.
Run: `mvn -q test`
Expected: PASS (full suite).
- [ ] **Step 8: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \
src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \
src/main/java/thb/jeanluc/adventure/command/impl/MapCommand.java \
src/main/java/thb/jeanluc/adventure/App.java \
src/test/java/thb/jeanluc/adventure/command/impl/MapCommandTest.java
git commit -m "feat(io,command): map command + GameIO setMap/showMap, console ASCII"
```
---
## Task 6: Push the map each turn from `Game`
**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java`
- [ ] **Step 1: Update the per-turn publish**
Add imports:
```java
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;
```
Replace the `publishHud()` method with a combined publish:
```java
private void publishHud() {
ctx.getIo().setHud(new Hud(
ctx.getPlayer().getCurrentRoom().getName(),
ctx.getPlayer().getGold(),
turn,
false));
MapView map = MapLayout.compute(
ctx.getWorld(),
ctx.getPlayer().getVisitedRoomIds(),
ctx.getPlayer().getCurrentRoom());
ctx.getIo().setMap(map);
}
```
- [ ] **Step 2: Run full suite**
Run: `mvn -q test`
Expected: PASS — `TestIO.setMap` is the no-op default; `GameTest` unaffected.
- [ ] **Step 3: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/game/Game.java
git commit -m "feat(game): push map to the IO each turn (GUI live update)"
```
---
## Task 7: `MapPanel` (Graphics2D) and `SwingIO` integration
No unit test (Graphics2D/EDT). Verified manually via `mvn exec:java@gui`.
**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/io/MapPanel.java`
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
- [ ] **Step 1: Create `MapPanel.java`**
```java
package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.CellState;
import thb.jeanluc.adventure.io.text.Connection;
import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.io.text.RoomCell;
import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Stroke;
import java.util.List;
/** Draws a {@link MapView} as outlined rooms linked by corridors (Graphics2D). */
public class MapPanel extends JComponent {
private static final int CELL_W = 96;
private static final int CELL_H = 46;
private static final int GAP_X = 34;
private static final int GAP_Y = 30;
private static final int PAD = 16;
private static final Color BG = new Color(0x0e, 0x12, 0x18);
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
private final Font font;
private MapView view = new MapView(List.of(), List.of());
public MapPanel(Font font) {
this.font = font.deriveFont(11f);
setBackground(BG);
setOpaque(true);
}
/** Replaces the rendered map and scrolls the current room into view. */
public void show(MapView v) {
SwingUtilities.invokeLater(() -> {
this.view = v;
int maxX = 0;
int maxY = 0;
for (RoomCell c : v.cells()) {
maxX = Math.max(maxX, c.x());
maxY = Math.max(maxY, c.y());
}
setPreferredSize(new Dimension(
PAD * 2 + (maxX + 1) * CELL_W + maxX * GAP_X,
PAD * 2 + (maxY + 1) * CELL_H + maxY * GAP_Y));
revalidate();
repaint();
v.cells().stream().filter(c -> c.state() == CellState.CURRENT).findFirst()
.ifPresent(c -> scrollRectToVisible(boundsExpanded(c)));
});
}
private Rectangle boundsExpanded(RoomCell c) {
int x = PAD + c.x() * (CELL_W + GAP_X);
int y = PAD + c.y() * (CELL_H + GAP_Y);
return new Rectangle(x - GAP_X, y - GAP_Y, CELL_W + 2 * GAP_X, CELL_H + 2 * GAP_Y);
}
private int cx(RoomCell c) {
return PAD + c.x() * (CELL_W + GAP_X);
}
private int cy(RoomCell c) {
return PAD + c.y() * (CELL_H + GAP_Y);
}
@Override
protected void paintComponent(Graphics g0) {
Graphics2D g = (Graphics2D) g0.create();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(BG);
g.fillRect(0, 0, getWidth(), getHeight());
g.setFont(font);
Stroke solid = new BasicStroke(2f);
Stroke dashed = new BasicStroke(1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
10f, new float[]{4f, 3f}, 0f);
// corridors first
for (Connection cn : view.connections()) {
boolean known = cn.from().state() == CellState.KNOWN || cn.to().state() == CellState.KNOWN;
g.setStroke(known ? dashed : solid);
g.setColor(known ? KNOWN : CORRIDOR);
int x1 = cx(cn.from()) + CELL_W / 2;
int y1 = cy(cn.from()) + CELL_H / 2;
int x2 = cx(cn.to()) + CELL_W / 2;
int y2 = cy(cn.to()) + CELL_H / 2;
g.drawLine(x1, y1, x2, y2);
}
// rooms on top
for (RoomCell c : view.cells()) {
int x = cx(c);
int y = cy(c);
Color stroke = switch (c.state()) {
case CURRENT -> CURRENT;
case VISITED -> VISITED;
case KNOWN -> KNOWN;
};
if (c.state() == CellState.CURRENT) {
g.setColor(new Color(0x27, 0x21, 0x10));
g.fillRoundRect(x, y, CELL_W, CELL_H, 12, 12);
} else {
g.setColor(new Color(0x11, 0x16, 0x1f));
g.fillRoundRect(x, y, CELL_W, CELL_H, 12, 12);
}
g.setStroke(c.state() == CellState.KNOWN ? dashed : new BasicStroke(c.state() == CellState.CURRENT ? 2.5f : 1.5f));
g.setColor(stroke);
g.drawRoundRect(x, y, CELL_W, CELL_H, 12, 12);
String label = c.state() == CellState.KNOWN ? "?" : c.name();
int tw = g.getFontMetrics().stringWidth(label);
g.setColor(c.state() == CellState.KNOWN ? KNOWN.brighter() : stroke);
g.drawString(label, x + (CELL_W - tw) / 2, y + CELL_H / 2 + 4);
}
g.dispose();
}
}
```
- [ ] **Step 2: Integrate into `SwingIO.java`**
(a) Add import:
```java
import thb.jeanluc.adventure.io.text.MapView;
```
(b) Replace the `side` field declaration:
```java
private final JTextArea side;
```
with:
```java
private final MapPanel map;
```
(c) In the constructor, replace the side-panel setup block:
```java
side = new JTextArea();
side.setEditable(false);
side.setBackground(new Color(0x0e, 0x12, 0x18));
side.setForeground(new Color(0x6f, 0xcf, 0x73));
side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
JScrollPane sideScroll = new JScrollPane(side);
```
with:
```java
map = new MapPanel(baseFont);
JScrollPane sideScroll = new JScrollPane(map);
sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));
```
(d) Remove the side-panel font line in `applyFonts()`:
```java
side.setFont(baseFont.deriveFont(small));
```
(delete that single line; `map` manages its own font).
(e) In `showRoom`, delete the exits side-panel block (the map now shows exits):
```java
String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits());
SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits));
```
(delete those two lines).
(f) Add the map overrides (next to `setHud`):
```java
@Override
public void setMap(MapView view) {
map.show(view);
}
@Override
public void showMap(MapView view) {
// The GUI map panel is always visible; nothing extra to do on the 'map' command.
}
```
- [ ] **Step 3: Build and run the suite**
Run: `mvn -q test`
Expected: PASS (79 + new tests).
- [ ] **Step 4: Manual GUI smoke test**
Run: `mvn -q -DskipTests exec:java@gui`
Expected: a window where the right panel shows the current room as a gold box with dashed `?` stubs for unexplored exits; typing `go north` / `go east` reveals rooms and the corridors connect them; the panel scrolls/centres on the current room. Close the window to exit.
- [ ] **Step 5: Commit**
```bash
git add src/main/java/thb/jeanluc/adventure/io/MapPanel.java \
src/main/java/thb/jeanluc/adventure/io/SwingIO.java
git commit -m "feat(io): GUI MapPanel (Graphics2D) in the side panel; drop exits text"
```
---
## Task 8: Final verification + docs
**Files:**
- Modify: `README.md` (commands table), `docs/enhancement-ideas.md` (mark map done)
- [ ] **Step 1: Add `map` to the README commands table**
In `README.md`, add a row to the command table (after the `look` row):
```markdown
| `map` | `m` | Karte der erkundeten Räume anzeigen |
```
- [ ] **Step 2: Mark sub-project 2 done in the backlog**
In `docs/enhancement-ideas.md`, change the heading `### 3. Karte / Map` to:
```markdown
### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
```
- [ ] **Step 3: Full suite + console smoke**
Run: `mvn -q test`
Expected: PASS.
Run: `printf 'look\ngo north\nmap\nquit\n' | mvn -q -DskipTests exec:java@run`
Expected: after `go north`, `map` prints an ASCII map showing Hallway (current) and Kitchen (visited), with `?` stubs and corridor characters; no exceptions.
- [ ] **Step 4: Commit**
```bash
git add README.md docs/enhancement-ideas.md
git commit -m "docs: document map command; mark map sub-project implemented"
```
---
## Self-Review notes
- **Spec coverage:** MapView model (T2), MapLayout BFS + fog + collision + normalise (T3), visited tracking (T1), AsciiMap console (T4), GameIO setMap/showMap + ConsoleIO override + MapCommand (T5), per-turn GUI push (T6), MapPanel Graphics2D + SwingIO integration replacing exits text (T7), docs (T8). Item/NPC markers and locked-door styling explicitly deferred.
- **Type consistency:** `MapLayout.compute(World, Set<String>, Room)`, `MapView(cells, connections)`, `RoomCell(id,name,x,y,state)`, `Connection(from,to)`, `CellState{CURRENT,VISITED,KNOWN}`, `AsciiMap.render(MapView, boolean)`, `GameIO.setMap/showMap`, `MapPanel.show(MapView)` used consistently across tasks.
- **Layering:** `io.text` stays free of `io`/`map` dependencies (AsciiMap takes a `boolean`, not `ConsoleIO.GlyphMode`). `map.MapLayout` depends on `model` + `io.text` only.