Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md
2026-05-31 20:13:30 +02:00

1194 lines
40 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 -> "";
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<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.