# 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 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 cells, List 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 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 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 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 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 coords = bfs(anchor); // Determine which rooms are placed: visited (with coords) + their unentered neighbours. Map 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 cells = new LinkedHashMap<>(); for (Map.Entry 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 connections = new ArrayList<>(); Set 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 bfs(Room anchor) { Map coords = new HashMap<>(); Deque 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 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 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 hEdge = new HashSet<>(); Set 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 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 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, 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.