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

37 KiB

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


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.javasetMap/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)

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

import java.util.LinkedHashSet;
import java.util.Set;

Add the field (with the other fields; the class is already @Getter):

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

    /** Room the player is currently standing in. */
    @Setter
    private Room currentRoom;

to:

    /** Room the player is currently standing in. */
    private Room currentRoom;

and add this method (anywhere among the methods):

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

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

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:

package thb.jeanluc.adventure.io.text;

/** Fog-of-war state of a room on the map. */
public enum CellState { CURRENT, VISITED, KNOWN }

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

package thb.jeanluc.adventure.io.text;

/** A corridor between two placed rooms. */
public record Connection(RoomCell from, RoomCell to) {}

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

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

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

import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.MapView;

Add inside the interface (after setHud):

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

import thb.jeanluc.adventure.io.text.AsciiMap;
import thb.jeanluc.adventure.io.text.MapView;

Add method:

    @Override
    public void showMap(MapView view) {
        print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
    }
  • Step 3: Write the failing MapCommandTest
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
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:

        registry.register(new MapCommand(), "map", "m");

Add the import:

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

import thb.jeanluc.adventure.io.text.MapView;
import thb.jeanluc.adventure.map.MapLayout;

Replace the publishHud() method with a combined publish:

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

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:

import thb.jeanluc.adventure.io.text.MapView;

(b) Replace the side field declaration:

    private final JTextArea side;

with:

    private final MapPanel map;

(c) In the constructor, replace the side-panel setup block:

        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:

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

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

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

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

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

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