diff --git a/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md b/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md new file mode 100644 index 0000000..3dc3951 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md @@ -0,0 +1,1193 @@ +# 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.