# Presentation/Output Layer 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:** Replace the plain `GameIO.write(String)` channel with a semantic, role-styled output layer so both the console and the Swing GUI can render rich output from the same frontend-agnostic game loop.

**Architecture:** Commands emit *meaning* (`StyledText`, `RoomView`, `Hud`) — not formatting. `GameIO` exposes `print(StyledText)` as the abstract primitive plus `showRoom`/`setHud`/`write` defaults. `ConsoleIO` renders to ANSI + box-drawing; `SwingIO` renders into a 4-region window with a bundled font. Existing 67 tests stay green because `TestIO` flattens styled output to plain text.

**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok (existing), Swing (`JTextPane`/`StyledDocument`).

Spec: [docs/superpowers/specs/2026-05-31-presentation-layer-design.md](../specs/2026-05-31-presentation-layer-design.md)

---

## File Structure

**Create:**
- `src/main/java/thb/jeanluc/adventure/io/text/Style.java` — role enum
- `src/main/java/thb/jeanluc/adventure/io/text/Span.java` — text+style record
- `src/main/java/thb/jeanluc/adventure/io/text/StyledText.java` — span list + fluent builder
- `src/main/java/thb/jeanluc/adventure/io/text/RoomView.java` — semantic room view
- `src/main/java/thb/jeanluc/adventure/io/text/Hud.java` — status snapshot
- `src/main/java/thb/jeanluc/adventure/io/text/Renderings.java` — RoomView → generic StyledText (default rendering)
- `src/main/java/thb/jeanluc/adventure/io/text/Banner.java` — welcome banner StyledText
- `src/main/resources/fonts/README.md` — drop-in instructions for a bundled `.ttf`
- `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java`
- `src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java`
- `src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java`

**Modify:**
- `src/main/java/thb/jeanluc/adventure/io/GameIO.java` — add `print`/`showRoom`/`setHud`, `write` default
- `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` — render ANSI + box-drawing + HUD; color/glyph modes
- `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` — 4-region layout, `JTextPane`, bundled font
- `src/test/java/thb/jeanluc/adventure/io/TestIO.java` — flatten styled output to plain text
- `src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java` — emit `RoomView`
- `src/main/java/thb/jeanluc/adventure/game/Game.java` — per-turn HUD update
- `src/main/java/thb/jeanluc/adventure/App.java` — welcome banner

---

## Task 1: Semantic text model (`Style`, `Span`, `StyledText`)

**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Style.java`
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Span.java`
- Create: `src/main/java/thb/jeanluc/adventure/io/text/StyledText.java`
- Test: `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java`

- [ ] **Step 1: Write the failing test**

```java
package thb.jeanluc.adventure.io.text;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class StyledTextTest {

    @Test
    void builder_collectsSpansInOrder() {
        StyledText t = StyledText.builder()
                .plain("You take ").item("the brass lamp").plain(".").build();
        assertThat(t.spans()).hasSize(3);
        assertThat(t.spans().get(1).style()).isEqualTo(Style.ITEM);
        assertThat(t.spans().get(1).text()).isEqualTo("the brass lamp");
    }

    @Test
    void plainText_concatenatesAllSpanText() {
        StyledText t = StyledText.builder()
                .plain("You take ").item("the brass lamp").plain(".").build();
        assertThat(t.plainText()).isEqualTo("You take the brass lamp.");
    }

    @Test
    void of_createsSinglePlainSpan() {
        StyledText t = StyledText.of("hello");
        assertThat(t.spans()).hasSize(1);
        assertThat(t.spans().getFirst().style()).isEqualTo(Style.PLAIN);
        assertThat(t.plainText()).isEqualTo("hello");
    }

    @Test
    void span_rejectsNulls() {
        assertThatThrownBy(() -> new Span(null, Style.PLAIN))
                .isInstanceOf(IllegalArgumentException.class);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `mvn -q test -Dtest=StyledTextTest`
Expected: FAIL — compilation error, `Style`/`Span`/`StyledText` do not exist.

- [ ] **Step 3: Write the implementation**

`Style.java`:
```java
package thb.jeanluc.adventure.io.text;

/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
```

`Span.java`:
```java
package thb.jeanluc.adventure.io.text;

/** A run of text carrying a single semantic {@link Style}. */
public record Span(String text, Style style) {
    public Span {
        if (text == null) {
            throw new IllegalArgumentException("text must not be null");
        }
        if (style == null) {
            throw new IllegalArgumentException("style must not be null");
        }
    }
}
```

`StyledText.java`:
```java
package thb.jeanluc.adventure.io.text;

import java.util.ArrayList;
import java.util.List;

/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
public final class StyledText {

    private final List<Span> spans;

    private StyledText(List<Span> spans) {
        this.spans = List.copyOf(spans);
    }

    /** @return the spans in order (unmodifiable). */
    public List<Span> spans() {
        return spans;
    }

    /** @return all span text concatenated, without any styling. */
    public String plainText() {
        StringBuilder sb = new StringBuilder();
        for (Span s : spans) {
            sb.append(s.text());
        }
        return sb.toString();
    }

    /** @return a styled text of a single {@link Style#PLAIN} span. */
    public static StyledText of(String s) {
        return new StyledText(List.of(new Span(s, Style.PLAIN)));
    }

    public static Builder builder() {
        return new Builder();
    }

    /** Fluent builder; one method per {@link Style} role. */
    public static final class Builder {
        private final List<Span> spans = new ArrayList<>();

        private Builder add(String text, Style style) {
            spans.add(new Span(text, style));
            return this;
        }

        public Builder plain(String s)   { return add(s, Style.PLAIN); }
        public Builder heading(String s) { return add(s, Style.HEADING); }
        public Builder item(String s)    { return add(s, Style.ITEM); }
        public Builder npc(String s)     { return add(s, Style.NPC); }
        public Builder exit(String s)    { return add(s, Style.EXIT); }
        public Builder danger(String s)  { return add(s, Style.DANGER); }
        public Builder dim(String s)     { return add(s, Style.DIM); }

        public StyledText build() {
            return new StyledText(spans);
        }
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `mvn -q test -Dtest=StyledTextTest`
Expected: PASS (4 tests).

- [ ] **Step 5: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/io/text/Style.java \
        src/main/java/thb/jeanluc/adventure/io/text/Span.java \
        src/main/java/thb/jeanluc/adventure/io/text/StyledText.java \
        src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java
git commit -m "feat(io): semantic styled-text model (Style, Span, StyledText)"
```

---

## Task 2: Views + default rendering (`RoomView`, `Hud`, `Renderings`)

**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/io/text/RoomView.java`
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Hud.java`
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Renderings.java`
- Test: `src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.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 RenderingsTest {

    @Test
    void roomToStyledText_includesNameDescriptionItemsNpcsExits() {
        RoomView room = new RoomView("Kitchen", "A cold kitchen.",
                List.of("Lamp", "Letter"), List.of("Old Man"), List.of("north", "east"));

        String text = Renderings.roomToStyledText(room).plainText();

        assertThat(text).contains("Kitchen");
        assertThat(text).contains("A cold kitchen.");
        assertThat(text).contains("Lamp").contains("Letter");
        assertThat(text).contains("Old Man");
        assertThat(text).contains("north").contains("east");
    }

    @Test
    void roomToStyledText_noExits_saysNoObviousExits() {
        RoomView room = new RoomView("Void", "Nothing.", List.of(), List.of(), List.of());
        assertThat(Renderings.roomToStyledText(room).plainText())
                .contains("no obvious exits");
    }

    @Test
    void roomToStyledText_tagsItemSpansWithItemStyle() {
        RoomView room = new RoomView("Kitchen", "desc",
                List.of("Lamp"), List.of(), List.of("north"));
        boolean hasItemSpan = Renderings.roomToStyledText(room).spans().stream()
                .anyMatch(s -> s.style() == Style.ITEM && s.text().equals("Lamp"));
        assertThat(hasItemSpan).isTrue();
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `mvn -q test -Dtest=RenderingsTest`
Expected: FAIL — `RoomView`/`Renderings` do not exist.

- [ ] **Step 3: Write the implementation**

`RoomView.java`:
```java
package thb.jeanluc.adventure.io.text;

import java.util.List;

/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */
public record RoomView(String name, String description,
                       List<String> items, List<String> npcs, List<String> exits) {
    public RoomView {
        items = List.copyOf(items);
        npcs = List.copyOf(npcs);
        exits = List.copyOf(exits);
    }
}
```

`Hud.java`:
```java
package thb.jeanluc.adventure.io.text;

/** Status snapshot for the HUD region. */
public record Hud(String location, int gold, int turn, boolean lightOn) {}
```

`Renderings.java`:
```java
package thb.jeanluc.adventure.io.text;

import java.util.List;

/** Default, layout-free rendering of high-level views into {@link StyledText}. */
public final class Renderings {

    private Renderings() {
    }

    /** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */
    public static StyledText roomToStyledText(RoomView r) {
        StyledText.Builder b = StyledText.builder();
        b.heading(r.name()).plain("\n");
        b.plain(r.description().stripTrailing());
        if (!r.items().isEmpty()) {
            b.plain("\nYou see: ");
            appendList(b, r.items(), Style.ITEM);
            b.plain(".");
        }
        if (!r.npcs().isEmpty()) {
            b.plain("\nHere is: ");
            appendList(b, r.npcs(), Style.NPC);
            b.plain(".");
        }
        if (r.exits().isEmpty()) {
            b.plain("\nThere are no obvious exits.");
        } else {
            b.plain("\nExits: ");
            appendList(b, r.exits(), Style.EXIT);
            b.plain(".");
        }
        return b.build();
    }

    private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
        for (int i = 0; i < xs.size(); i++) {
            if (i > 0) {
                b.plain(", ");
            }
            switch (st) {
                case ITEM -> b.item(xs.get(i));
                case NPC -> b.npc(xs.get(i));
                case EXIT -> b.exit(xs.get(i));
                default -> b.plain(xs.get(i));
            }
        }
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `mvn -q test -Dtest=RenderingsTest`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/io/text/RoomView.java \
        src/main/java/thb/jeanluc/adventure/io/text/Hud.java \
        src/main/java/thb/jeanluc/adventure/io/text/Renderings.java \
        src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java
git commit -m "feat(io): RoomView/Hud views and default Renderings"
```

---

## Task 3: Extend `GameIO`, keep all implementors compiling and tests green

This makes `print(StyledText)` the abstract primitive and `write`/`showRoom`/`setHud` defaults, then gives each implementor a minimal `print` so the build stays green. Rich rendering comes in later tasks.

**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java`
- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java`
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
- Modify: `src/test/java/thb/jeanluc/adventure/io/TestIO.java`

- [ ] **Step 1: Rewrite `GameIO.java`**

```java
package thb.jeanluc.adventure.io;

import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.Renderings;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.StyledText;

/**
 * Bidirectional channel between the engine and the player. Commands emit
 * semantic, role-styled output via {@link #print(StyledText)} (the primitive)
 * and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
 */
public interface GameIO {

    /** Blocks until a line of input is available; empty string at end of input. */
    String readLine();

    /** Renders one styled output block. The only method implementors must provide. */
    void print(StyledText text);

    /** Convenience: a plain, unstyled line. */
    default void write(String s) {
        print(StyledText.of(s));
    }

    /** Renders a room. Default flattens via {@link Renderings}; renderers may override. */
    default void showRoom(RoomView room) {
        print(Renderings.roomToStyledText(room));
    }

    /** Updates the status/HUD region. No-op by default; renderers may override. */
    default void setHud(Hud hud) {
        // no-op
    }
}
```

- [ ] **Step 2: Give `ConsoleIO` a minimal `print` (full rendering in Task 4)**

Replace the `write` method in `ConsoleIO.java` with:
```java
    @Override
    public void print(StyledText text) {
        out.println(text.plainText());
    }
```
(Remove the old `@Override public void write(String s)` method; the `GameIO` default now covers it.)

- [ ] **Step 3: Give `SwingIO` a minimal `print` (full layout in Task 7)**

In `SwingIO.java`, replace the `write` method with:
```java
    @Override
    public void print(StyledText text) {
        SwingUtilities.invokeLater(() -> output.append(text.plainText() + "\n"));
    }
```
(Remove the old `@Override public void write(String s)` method.)

- [ ] **Step 4: Flatten styled output in `TestIO.java`**

Replace the `write` method in `TestIO.java` with the new primitive (and keep the `import`):
```java
    @Override
    public void print(thb.jeanluc.adventure.io.text.StyledText text) {
        outputs.add(text.plainText());
    }
```
(Remove the old `@Override public void write(String s)` method. `showRoom`/`setHud` use the `GameIO` defaults: `showRoom` flows through `print`, so room text is still captured in `outputs`.)

- [ ] **Step 5: Run the full suite**

Run: `mvn -q test`
Expected: PASS — all 67 existing tests still green (output now routed through `print`, plain-text content unchanged).

- [ ] **Step 6: 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/io/SwingIO.java \
        src/test/java/thb/jeanluc/adventure/io/TestIO.java
git commit -m "feat(io): make print(StyledText) the GameIO primitive; keep suite green"
```

---

## Task 4: `ConsoleIO` rich renderer (ANSI + box-drawing + HUD + modes)

**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java`
- Test: `src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java`

- [ ] **Step 1: Write the failing test** (colour OFF so assertions are on structure, not escape codes)

```java
package thb.jeanluc.adventure.io;

import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.StyledText;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

class ConsoleIORenderTest {

    private ConsoleIO io(ByteArrayOutputStream sink) {
        PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
        return new ConsoleIO(new BufferedReader(new StringReader("")), ps,
                ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
    }

    @Test
    void showRoom_drawsHeadingSectionsAndExits() {
        ByteArrayOutputStream sink = new ByteArrayOutputStream();
        io(sink).showRoom(new RoomView("Kitchen", "A cold kitchen.",
                List.of("Lamp"), List.of("Old Man"), List.of("north", "east")));
        String out = sink.toString(StandardCharsets.UTF_8);
        assertThat(out).contains("KITCHEN");
        assertThat(out).contains("A cold kitchen.");
        assertThat(out).contains("Lamp");
        assertThat(out).contains("Old Man");
        assertThat(out).contains("north").contains("east");
    }

    @Test
    void print_colorOff_emitsNoAnsiEscapes() {
        ByteArrayOutputStream sink = new ByteArrayOutputStream();
        io(sink).print(StyledText.builder().item("brass lamp").build());
        assertThat(sink.toString(StandardCharsets.UTF_8)).doesNotContain("[");
    }

    @Test
    void setHud_printsLocationGoldTurnAndLight() {
        ByteArrayOutputStream sink = new ByteArrayOutputStream();
        io(sink).setHud(new Hud("Kitchen", 0, 7, false));
        String out = sink.toString(StandardCharsets.UTF_8);
        assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off");
    }

    @Test
    void asciiGlyphMode_usesPlusDashFrame() {
        ByteArrayOutputStream sink = new ByteArrayOutputStream();
        PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
        new ConsoleIO(new BufferedReader(new StringReader("")), ps,
                ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.ASCII)
                .showRoom(new RoomView("Cell", "x", List.of(), List.of(), List.of("north")));
        String out = sink.toString(StandardCharsets.UTF_8);
        assertThat(out).contains("+").contains("-");
        assertThat(out).doesNotContain("─");
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `mvn -q test -Dtest=ConsoleIORenderTest`
Expected: FAIL — the 4-arg constructor and `ColorMode`/`GlyphMode` do not exist yet.

- [ ] **Step 3: Rewrite `ConsoleIO.java`**

```java
package thb.jeanluc.adventure.io;

import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
 * (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
 * and degrade safely (no colours when output is piped; ASCII frames on request).
 */
public class ConsoleIO implements GameIO {

    /** Whether ANSI colour is emitted. */
    public enum ColorMode { ON, OFF, AUTO }

    /** Frame/glyph fidelity tier. */
    public enum GlyphMode { ASCII, UNICODE, GLYPH }

    private static final int FRAME_WIDTH = 44;

    private final BufferedReader in;
    private final PrintStream out;
    private final boolean useColor;
    private final GlyphMode glyphs;

    public ConsoleIO() {
        this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
                System.out, ColorMode.AUTO, GlyphMode.UNICODE);
    }

    /** Backward-compatible stream constructor (colour off, unicode frames). */
    public ConsoleIO(BufferedReader in, PrintStream out) {
        this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
    }

    public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
        this.in = in;
        this.out = out;
        this.glyphs = glyphs;
        this.useColor = switch (colorMode) {
            case ON -> true;
            case OFF -> false;
            case AUTO -> System.console() != null;
        };
    }

    @Override
    public String readLine() {
        out.print("> ");
        out.flush();
        try {
            String line = in.readLine();
            return line == null ? "" : line;
        } catch (IOException e) {
            return "";
        }
    }

    @Override
    public void print(StyledText text) {
        StringBuilder sb = new StringBuilder();
        for (Span s : text.spans()) {
            sb.append(paint(s.style(), s.text()));
        }
        out.println(sb);
    }

    @Override
    public void showRoom(RoomView room) {
        out.println(paint(Style.HEADING, frameTop(room.name())));
        out.println(room.description().stripTrailing());
        if (!room.items().isEmpty()) {
            out.println(section("Items", room.items(), Style.ITEM));
        }
        if (!room.npcs().isEmpty()) {
            out.println(section("People", room.npcs(), Style.NPC));
        }
        if (!room.exits().isEmpty()) {
            out.println(section("Exits", room.exits(), Style.EXIT));
        } else {
            out.println("  " + paint(Style.DIM, "no obvious exits"));
        }
        out.println(paint(Style.HEADING, frameBottom()));
    }

    @Override
    public void setHud(Hud h) {
        String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn()
                + " · light: " + (h.lightOn() ? "on" : "off") + " ]";
        out.println(paint(Style.DIM, line));
    }

    private String section(String label, List<String> xs, Style st) {
        StringBuilder sb = new StringBuilder("  ").append(paint(Style.DIM, label)).append("  ");
        for (int i = 0; i < xs.size(); i++) {
            if (i > 0) {
                sb.append(" · ");
            }
            sb.append(paint(st, xs.get(i)));
        }
        return sb.toString();
    }

    private String frameTop(String title) {
        String tl = glyphs == GlyphMode.ASCII ? "+" : "┌";
        String tr = glyphs == GlyphMode.ASCII ? "+" : "┐";
        String h = glyphs == GlyphMode.ASCII ? "-" : "─";
        StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
                .append(title.toUpperCase()).append(' ');
        while (sb.length() < FRAME_WIDTH - 1) {
            sb.append(h);
        }
        return sb.append(tr).toString();
    }

    private String frameBottom() {
        String bl = glyphs == GlyphMode.ASCII ? "+" : "└";
        String br = glyphs == GlyphMode.ASCII ? "+" : "┘";
        String h = glyphs == GlyphMode.ASCII ? "-" : "─";
        StringBuilder sb = new StringBuilder(bl);
        while (sb.length() < FRAME_WIDTH - 1) {
            sb.append(h);
        }
        return sb.append(br).toString();
    }

    private String paint(Style st, String s) {
        if (!useColor) {
            return s;
        }
        String code = switch (st) {
            case HEADING -> "[1;35m";
            case ITEM -> "[36m";
            case NPC -> "[33m";
            case EXIT -> "[32m";
            case DANGER -> "[31m";
            case DIM -> "[90m";
            case PLAIN -> "";
        };
        return code.isEmpty() ? s : code + s + "[0m";
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `mvn -q test -Dtest=ConsoleIORenderTest`
Expected: PASS (4 tests).

- [ ] **Step 5: Run the full suite**

Run: `mvn -q test`
Expected: PASS (existing tests + new ones).

- [ ] **Step 6: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \
        src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java
git commit -m "feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes"
```

---

## Task 5: Migrate `LookCommand` to emit a `RoomView`

**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java`
- Test (existing, should stay green): `src/test/java/thb/jeanluc/adventure/command/impl/LookCommandTest.java`

- [ ] **Step 1: Rewrite `LookCommand.execute`**

```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.RoomView;
import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item;

import java.util.List;

/**
 * Describes the current room by emitting a {@link RoomView}; the active
 * frontend decides how to render it. Usage: {@code look}.
 */
public class LookCommand implements Command {

    @Override
    public void execute(GameContext ctx, List<String> args) {
        Room room = ctx.getPlayer().getCurrentRoom();
        List<String> items = room.getItems().values().stream().map(Item::getName).toList();
        List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
        List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
        ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits));
    }

    @Override
    public String help() {
        return "look  -  describe the current room";
    }
}
```

- [ ] **Step 2: Run the relevant tests**

Run: `mvn -q test -Dtest=LookCommandTest`
Expected: PASS — `TestIO.showRoom` uses the `GameIO` default → `Renderings` → `print`, so `lastOutput()` still contains "Kitchen", "kitchen desc", "Lamp", "Letter", "Old Man", "north".

- [ ] **Step 3: Run the full suite**

Run: `mvn -q test`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java
git commit -m "refactor(command): LookCommand emits semantic RoomView"
```

---

## Task 6: Per-turn HUD in `Game`

**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java`

- [ ] **Step 1: Add a turn counter and HUD update to the loop**

In `Game.java`, add the import and a field, and update the HUD after each handled command. Full updated `run()` plus the new field:

Add import:
```java
import thb.jeanluc.adventure.io.text.Hud;
```

Add field (next to `running`):
```java
    /** Number of commands processed this session; shown in the HUD. */
    private int turn = 0;
```

Replace `run()` with:
```java
    public void run() {
        publishHud();
        while (running) {
            String input = ctx.getIo().readLine();
            if (input == null) {
                break;
            }
            ParsedCommand parsed = parser.parse(input);
            if (parsed.verb().isEmpty()) {
                continue;
            }
            Optional<Command> cmd = registry.find(parsed.verb());
            if (cmd.isEmpty()) {
                ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
            } else {
                cmd.get().execute(ctx, parsed.args());
                turn++;
            }
            publishHud();
        }
    }

    private void publishHud() {
        ctx.getIo().setHud(new Hud(
                ctx.getPlayer().getCurrentRoom().getName(),
                ctx.getPlayer().getGold(),
                turn,
                false));
    }
```

- [ ] **Step 2: Run the full suite**

Run: `mvn -q test`
Expected: PASS — `GameTest` unaffected (`TestIO.setHud` is the no-op default; HUD output is not asserted).

- [ ] **Step 3: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/game/Game.java
git commit -m "feat(game): publish HUD (location, gold, turn) each loop iteration"
```

---

## Task 7: `SwingIO` 4-region layout, `JTextPane`, bundled font

No unit test (Swing/EDT). Verified manually via `mvn exec:java@gui`.

**Files:**
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
- Create: `src/main/resources/fonts/README.md`

- [ ] **Step 1: Rewrite `SwingIO.java`**

```java
package thb.jeanluc.adventure.io;

import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;

import javax.swing.BorderFactory;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsEnvironment;
import java.awt.Insets;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
 * a styled output pane (centre), a side panel for exits / future map (right),
 * and an input field (bottom). A bundled TrueType font is loaded if present so
 * colours and glyphs work without the player installing anything.
 */
public class SwingIO implements GameIO {

    private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
    private final JFrame frame;
    private final JTextPane output;
    private final StyledDocument doc;
    private final JTextField input;
    private final JLabel hud;
    private final JTextArea side;
    private final Font baseFont;

    public SwingIO(String title) {
        baseFont = loadFont();

        output = new JTextPane();
        output.setEditable(false);
        output.setBackground(new Color(0x0b, 0x0e, 0x13));
        output.setForeground(new Color(0xcf, 0xd6, 0xe0));
        output.setFont(baseFont);
        output.setMargin(new Insets(8, 8, 8, 8));
        doc = output.getStyledDocument();

        hud = new JLabel(" ");
        hud.setOpaque(true);
        hud.setBackground(new Color(0x11, 0x15, 0x1c));
        hud.setForeground(new Color(0x8b, 0x94, 0xa3));
        hud.setFont(baseFont.deriveFont(12f));
        hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));

        side = new JTextArea();
        side.setEditable(false);
        side.setBackground(new Color(0x0e, 0x12, 0x18));
        side.setForeground(new Color(0x6f, 0xcf, 0x73));
        side.setFont(baseFont.deriveFont(12f));
        side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
        JScrollPane sideScroll = new JScrollPane(side);
        sideScroll.setPreferredSize(new Dimension(210, 0));

        input = new JTextField();
        input.setFont(baseFont);
        input.addActionListener(e -> {
            String line = input.getText();
            input.setText("");
            appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
            inputs.offer(line);
        });

        frame = new JFrame(title);
        frame.setLayout(new BorderLayout());
        frame.add(hud, BorderLayout.NORTH);
        frame.add(new JScrollPane(output), BorderLayout.CENTER);
        frame.add(sideScroll, BorderLayout.EAST);
        frame.add(input, BorderLayout.SOUTH);
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(900, 600);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        input.requestFocusInWindow();
    }

    private Font loadFont() {
        try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
            if (in != null) {
                Font f = Font.createFont(Font.TRUETYPE_FONT, in).deriveFont(14f);
                GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
                return f;
            }
        } catch (Exception e) {
            // fall through to the logical monospaced fallback
        }
        return new Font(Font.MONOSPACED, Font.PLAIN, 14);
    }

    private Color colorFor(Style s) {
        return switch (s) {
            case HEADING -> new Color(0xc9, 0x8a, 0xe0);
            case ITEM -> new Color(0x46, 0xc8, 0xd8);
            case NPC -> new Color(0xe6, 0xc3, 0x4a);
            case EXIT -> new Color(0x6f, 0xcf, 0x73);
            case DANGER -> new Color(0xe0, 0x6c, 0x6c);
            case DIM -> new Color(0x6b, 0x72, 0x80);
            case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
        };
    }

    private void appendSpans(List<Span> spans) {
        SwingUtilities.invokeLater(() -> {
            try {
                for (Span sp : spans) {
                    SimpleAttributeSet as = new SimpleAttributeSet();
                    StyleConstants.setForeground(as, colorFor(sp.style()));
                    if (sp.style() == Style.HEADING) {
                        StyleConstants.setBold(as, true);
                    }
                    doc.insertString(doc.getLength(), sp.text(), as);
                }
                output.setCaretPosition(doc.getLength());
            } catch (BadLocationException ignored) {
                // append-only; cannot occur with getLength()
            }
        });
    }

    @Override
    public String readLine() {
        try {
            return inputs.take();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "";
        }
    }

    @Override
    public void print(StyledText text) {
        List<Span> spans = new ArrayList<>(text.spans());
        spans.add(new Span("\n", Style.PLAIN));
        appendSpans(spans);
    }

    @Override
    public void showRoom(RoomView room) {
        List<Span> spans = new ArrayList<>();
        spans.add(new Span(room.name() + "\n", Style.HEADING));
        spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
        if (!room.items().isEmpty()) {
            spans.add(new Span("You see ", Style.PLAIN));
            addList(spans, room.items(), Style.ITEM);
            spans.add(new Span(".\n", Style.PLAIN));
        }
        if (!room.npcs().isEmpty()) {
            addList(spans, room.npcs(), Style.NPC);
            spans.add(new Span(" is here.\n", Style.PLAIN));
        }
        appendSpans(spans);

        String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits());
        SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits));
    }

    @Override
    public void setHud(Hud h) {
        String txt = "  " + h.location() + "     gold " + h.gold() + "     turn " + h.turn()
                + "     light " + (h.lightOn() ? "on" : "off");
        SwingUtilities.invokeLater(() -> hud.setText(txt));
    }

    private void addList(List<Span> spans, List<String> xs, Style st) {
        for (int i = 0; i < xs.size(); i++) {
            if (i > 0) {
                spans.add(new Span(", ", Style.PLAIN));
            }
            spans.add(new Span(xs.get(i), st));
        }
    }
}
```

- [ ] **Step 2: Create the font drop-in note**

`src/main/resources/fonts/README.md`:
```markdown
# Bundled GUI font

Drop a TrueType font here named `game.ttf` to use it in the Swing GUI
(`SwingIO`). It is loaded via `Font.createFont` and registered at runtime —
no installation on the player's machine required.

- A monospaced font is recommended (the UI assumes fixed width for frames).
- A Nerd Font variant additionally provides icon glyphs.
- Check the font's licence before committing the `.ttf`.

If no `game.ttf` is present, `SwingIO` falls back to the logical
`Font.MONOSPACED`, so the game still runs.
```

- [ ] **Step 3: Verify it compiles and the suite is green**

Run: `mvn -q test`
Expected: PASS.

- [ ] **Step 4: Manual smoke test of the GUI**

Run: `mvn -q -DskipTests exec:java@gui`
Expected: a window opens with a HUD bar on top, styled room text in the centre, an EXITS list on the right, and an input field at the bottom. Typing `look`, `go north`, `take lamp` updates the regions. Close the window to exit.

- [ ] **Step 5: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/io/SwingIO.java \
        src/main/resources/fonts/README.md
git commit -m "feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader"
```

---

## Task 8: Welcome banner + wire-in

**Files:**
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Banner.java`
- Test: add to `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java` (or a new `BannerTest`)
- Modify: `src/main/java/thb/jeanluc/adventure/App.java`

- [ ] **Step 1: Write the failing test**

`src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java`:
```java
package thb.jeanluc.adventure.io.text;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class BannerTest {

    @Test
    void welcome_containsTitleAsHeading() {
        StyledText b = Banner.welcome("Haunted Manor");
        assertThat(b.plainText()).contains("Haunted Manor");
        boolean hasHeading = b.spans().stream().anyMatch(s -> s.style() == Style.HEADING);
        assertThat(hasHeading).isTrue();
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

Run: `mvn -q test -Dtest=BannerTest`
Expected: FAIL — `Banner` does not exist.

- [ ] **Step 3: Write `Banner.java`**

```java
package thb.jeanluc.adventure.io.text;

/** Builds styled banners for big moments (game start, finale). */
public final class Banner {

    private Banner() {
    }

    /** A framed, heading-styled welcome banner for the given title. */
    public static StyledText welcome(String title) {
        String bar = "=".repeat(Math.max(8, title.length() + 8));
        return StyledText.builder()
                .heading(bar + "\n")
                .heading("    " + title + "\n")
                .heading(bar)
                .build();
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `mvn -q test -Dtest=BannerTest`
Expected: PASS.

- [ ] **Step 5: Use the banner in `App.run`**

In `App.java`, add the import:
```java
import thb.jeanluc.adventure.io.text.Banner;
```
Replace these two lines in `run(GameIO io)`:
```java
        io.write(loaded.world().getTitle());
        io.write(loaded.world().getWelcomeMessage());
```
with:
```java
        io.print(Banner.welcome(loaded.world().getTitle()));
        io.write(loaded.world().getWelcomeMessage());
```

- [ ] **Step 6: Run the full suite**

Run: `mvn -q test`
Expected: PASS.

- [ ] **Step 7: Final verification (both frontends)**

Run: `mvn -q test`
Expected: PASS — full suite green.

Run: `printf 'look\ngo north\nquit\n' | mvn -q -DskipTests exec:java@run`
Expected: framed welcome banner, a boxed room view with coloured/ANSI sections (or plain when piped), HUD line, no exceptions.

- [ ] **Step 8: Commit**

```bash
git add src/main/java/thb/jeanluc/adventure/io/text/Banner.java \
        src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java \
        src/main/java/thb/jeanluc/adventure/App.java
git commit -m "feat(io): welcome banner for game start"
```

---

## Self-Review notes

- **Spec coverage:** model (T1), views + default rendering (T2), GameIO extension + migration (T3), ConsoleIO B-baseline + modes + glyph fallback (T4), command migration (T5), HUD (T6), SwingIO region layout + bundled font + nerd-font solution (T7), ASCII-art banner mechanism (T8). Map/menu/save/music explicitly deferred — side panel slot delivered in T7.
- **Type consistency:** `print(StyledText)`, `showRoom(RoomView)`, `setHud(Hud)`, `StyledText.builder()/of()/spans()/plainText()`, `Renderings.roomToStyledText`, `ConsoleIO.ColorMode/GlyphMode`, `Banner.welcome` are used consistently across tasks.
- **Open spec detail:** `Hud.lightOn` is wired as constant `false` (T6) until the light mechanic arrives with content — matches spec §11.
