# 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 spans; private StyledText(List spans) { this.spans = List.copyOf(spans); } /** @return the spans in order (unmodifiable). */ public List 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 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 items, List npcs, List 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 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 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 -> ""; case ITEM -> ""; case NPC -> ""; case EXIT -> ""; case DANGER -> ""; case DIM -> ""; case PLAIN -> ""; }; return code.isEmpty() ? s : code + s + ""; } } ``` - [ ] **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 args) { Room room = ctx.getPlayer().getCurrentRoom(); List items = room.getItems().values().stream().map(Item::getName).toList(); List npcs = room.getNpcs().values().stream().map(Npc::getName).toList(); List 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 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 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 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 spans = new ArrayList<>(text.spans()); spans.add(new Span("\n", Style.PLAIN)); appendSpans(spans); } @Override public void showRoom(RoomView room) { List 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 spans, List 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.