1194 lines
40 KiB
Markdown
1194 lines
40 KiB
Markdown
# Presentation/Output Layer Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the plain `GameIO.write(String)` channel with a semantic, role-styled output layer so both the console and the Swing GUI can render rich output from the same frontend-agnostic game loop.
|
||
|
||
**Architecture:** Commands emit *meaning* (`StyledText`, `RoomView`, `Hud`) — not formatting. `GameIO` exposes `print(StyledText)` as the abstract primitive plus `showRoom`/`setHud`/`write` defaults. `ConsoleIO` renders to ANSI + box-drawing; `SwingIO` renders into a 4-region window with a bundled font. Existing 67 tests stay green because `TestIO` flattens styled output to plain text.
|
||
|
||
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok (existing), Swing (`JTextPane`/`StyledDocument`).
|
||
|
||
Spec: [docs/superpowers/specs/2026-05-31-presentation-layer-design.md](../specs/2026-05-31-presentation-layer-design.md)
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
**Create:**
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/Style.java` — role enum
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/Span.java` — text+style record
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/StyledText.java` — span list + fluent builder
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/RoomView.java` — semantic room view
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/Hud.java` — status snapshot
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/Renderings.java` — RoomView → generic StyledText (default rendering)
|
||
- `src/main/java/thb/jeanluc/adventure/io/text/Banner.java` — welcome banner StyledText
|
||
- `src/main/resources/fonts/README.md` — drop-in instructions for a bundled `.ttf`
|
||
- `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java`
|
||
- `src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java`
|
||
- `src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java`
|
||
|
||
**Modify:**
|
||
- `src/main/java/thb/jeanluc/adventure/io/GameIO.java` — add `print`/`showRoom`/`setHud`, `write` default
|
||
- `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` — render ANSI + box-drawing + HUD; color/glyph modes
|
||
- `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` — 4-region layout, `JTextPane`, bundled font
|
||
- `src/test/java/thb/jeanluc/adventure/io/TestIO.java` — flatten styled output to plain text
|
||
- `src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java` — emit `RoomView`
|
||
- `src/main/java/thb/jeanluc/adventure/game/Game.java` — per-turn HUD update
|
||
- `src/main/java/thb/jeanluc/adventure/App.java` — welcome banner
|
||
|
||
---
|
||
|
||
## Task 1: Semantic text model (`Style`, `Span`, `StyledText`)
|
||
|
||
**Files:**
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Style.java`
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Span.java`
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/StyledText.java`
|
||
- Test: `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||
|
||
class StyledTextTest {
|
||
|
||
@Test
|
||
void builder_collectsSpansInOrder() {
|
||
StyledText t = StyledText.builder()
|
||
.plain("You take ").item("the brass lamp").plain(".").build();
|
||
assertThat(t.spans()).hasSize(3);
|
||
assertThat(t.spans().get(1).style()).isEqualTo(Style.ITEM);
|
||
assertThat(t.spans().get(1).text()).isEqualTo("the brass lamp");
|
||
}
|
||
|
||
@Test
|
||
void plainText_concatenatesAllSpanText() {
|
||
StyledText t = StyledText.builder()
|
||
.plain("You take ").item("the brass lamp").plain(".").build();
|
||
assertThat(t.plainText()).isEqualTo("You take the brass lamp.");
|
||
}
|
||
|
||
@Test
|
||
void of_createsSinglePlainSpan() {
|
||
StyledText t = StyledText.of("hello");
|
||
assertThat(t.spans()).hasSize(1);
|
||
assertThat(t.spans().getFirst().style()).isEqualTo(Style.PLAIN);
|
||
assertThat(t.plainText()).isEqualTo("hello");
|
||
}
|
||
|
||
@Test
|
||
void span_rejectsNulls() {
|
||
assertThatThrownBy(() -> new Span(null, Style.PLAIN))
|
||
.isInstanceOf(IllegalArgumentException.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `mvn -q test -Dtest=StyledTextTest`
|
||
Expected: FAIL — compilation error, `Style`/`Span`/`StyledText` do not exist.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
`Style.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
|
||
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }
|
||
```
|
||
|
||
`Span.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
/** A run of text carrying a single semantic {@link Style}. */
|
||
public record Span(String text, Style style) {
|
||
public Span {
|
||
if (text == null) {
|
||
throw new IllegalArgumentException("text must not be null");
|
||
}
|
||
if (style == null) {
|
||
throw new IllegalArgumentException("style must not be null");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`StyledText.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
|
||
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
|
||
public final class StyledText {
|
||
|
||
private final List<Span> spans;
|
||
|
||
private StyledText(List<Span> spans) {
|
||
this.spans = List.copyOf(spans);
|
||
}
|
||
|
||
/** @return the spans in order (unmodifiable). */
|
||
public List<Span> spans() {
|
||
return spans;
|
||
}
|
||
|
||
/** @return all span text concatenated, without any styling. */
|
||
public String plainText() {
|
||
StringBuilder sb = new StringBuilder();
|
||
for (Span s : spans) {
|
||
sb.append(s.text());
|
||
}
|
||
return sb.toString();
|
||
}
|
||
|
||
/** @return a styled text of a single {@link Style#PLAIN} span. */
|
||
public static StyledText of(String s) {
|
||
return new StyledText(List.of(new Span(s, Style.PLAIN)));
|
||
}
|
||
|
||
public static Builder builder() {
|
||
return new Builder();
|
||
}
|
||
|
||
/** Fluent builder; one method per {@link Style} role. */
|
||
public static final class Builder {
|
||
private final List<Span> spans = new ArrayList<>();
|
||
|
||
private Builder add(String text, Style style) {
|
||
spans.add(new Span(text, style));
|
||
return this;
|
||
}
|
||
|
||
public Builder plain(String s) { return add(s, Style.PLAIN); }
|
||
public Builder heading(String s) { return add(s, Style.HEADING); }
|
||
public Builder item(String s) { return add(s, Style.ITEM); }
|
||
public Builder npc(String s) { return add(s, Style.NPC); }
|
||
public Builder exit(String s) { return add(s, Style.EXIT); }
|
||
public Builder danger(String s) { return add(s, Style.DANGER); }
|
||
public Builder dim(String s) { return add(s, Style.DIM); }
|
||
|
||
public StyledText build() {
|
||
return new StyledText(spans);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `mvn -q test -Dtest=StyledTextTest`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/io/text/Style.java \
|
||
src/main/java/thb/jeanluc/adventure/io/text/Span.java \
|
||
src/main/java/thb/jeanluc/adventure/io/text/StyledText.java \
|
||
src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java
|
||
git commit -m "feat(io): semantic styled-text model (Style, Span, StyledText)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Views + default rendering (`RoomView`, `Hud`, `Renderings`)
|
||
|
||
**Files:**
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/RoomView.java`
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Hud.java`
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Renderings.java`
|
||
- Test: `src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
import java.util.List;
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class RenderingsTest {
|
||
|
||
@Test
|
||
void roomToStyledText_includesNameDescriptionItemsNpcsExits() {
|
||
RoomView room = new RoomView("Kitchen", "A cold kitchen.",
|
||
List.of("Lamp", "Letter"), List.of("Old Man"), List.of("north", "east"));
|
||
|
||
String text = Renderings.roomToStyledText(room).plainText();
|
||
|
||
assertThat(text).contains("Kitchen");
|
||
assertThat(text).contains("A cold kitchen.");
|
||
assertThat(text).contains("Lamp").contains("Letter");
|
||
assertThat(text).contains("Old Man");
|
||
assertThat(text).contains("north").contains("east");
|
||
}
|
||
|
||
@Test
|
||
void roomToStyledText_noExits_saysNoObviousExits() {
|
||
RoomView room = new RoomView("Void", "Nothing.", List.of(), List.of(), List.of());
|
||
assertThat(Renderings.roomToStyledText(room).plainText())
|
||
.contains("no obvious exits");
|
||
}
|
||
|
||
@Test
|
||
void roomToStyledText_tagsItemSpansWithItemStyle() {
|
||
RoomView room = new RoomView("Kitchen", "desc",
|
||
List.of("Lamp"), List.of(), List.of("north"));
|
||
boolean hasItemSpan = Renderings.roomToStyledText(room).spans().stream()
|
||
.anyMatch(s -> s.style() == Style.ITEM && s.text().equals("Lamp"));
|
||
assertThat(hasItemSpan).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `mvn -q test -Dtest=RenderingsTest`
|
||
Expected: FAIL — `RoomView`/`Renderings` do not exist.
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
`RoomView.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
import java.util.List;
|
||
|
||
/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */
|
||
public record RoomView(String name, String description,
|
||
List<String> items, List<String> npcs, List<String> exits) {
|
||
public RoomView {
|
||
items = List.copyOf(items);
|
||
npcs = List.copyOf(npcs);
|
||
exits = List.copyOf(exits);
|
||
}
|
||
}
|
||
```
|
||
|
||
`Hud.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
/** Status snapshot for the HUD region. */
|
||
public record Hud(String location, int gold, int turn, boolean lightOn) {}
|
||
```
|
||
|
||
`Renderings.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
import java.util.List;
|
||
|
||
/** Default, layout-free rendering of high-level views into {@link StyledText}. */
|
||
public final class Renderings {
|
||
|
||
private Renderings() {
|
||
}
|
||
|
||
/** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */
|
||
public static StyledText roomToStyledText(RoomView r) {
|
||
StyledText.Builder b = StyledText.builder();
|
||
b.heading(r.name()).plain("\n");
|
||
b.plain(r.description().stripTrailing());
|
||
if (!r.items().isEmpty()) {
|
||
b.plain("\nYou see: ");
|
||
appendList(b, r.items(), Style.ITEM);
|
||
b.plain(".");
|
||
}
|
||
if (!r.npcs().isEmpty()) {
|
||
b.plain("\nHere is: ");
|
||
appendList(b, r.npcs(), Style.NPC);
|
||
b.plain(".");
|
||
}
|
||
if (r.exits().isEmpty()) {
|
||
b.plain("\nThere are no obvious exits.");
|
||
} else {
|
||
b.plain("\nExits: ");
|
||
appendList(b, r.exits(), Style.EXIT);
|
||
b.plain(".");
|
||
}
|
||
return b.build();
|
||
}
|
||
|
||
private static void appendList(StyledText.Builder b, List<String> xs, Style st) {
|
||
for (int i = 0; i < xs.size(); i++) {
|
||
if (i > 0) {
|
||
b.plain(", ");
|
||
}
|
||
switch (st) {
|
||
case ITEM -> b.item(xs.get(i));
|
||
case NPC -> b.npc(xs.get(i));
|
||
case EXIT -> b.exit(xs.get(i));
|
||
default -> b.plain(xs.get(i));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `mvn -q test -Dtest=RenderingsTest`
|
||
Expected: PASS (3 tests).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/io/text/RoomView.java \
|
||
src/main/java/thb/jeanluc/adventure/io/text/Hud.java \
|
||
src/main/java/thb/jeanluc/adventure/io/text/Renderings.java \
|
||
src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java
|
||
git commit -m "feat(io): RoomView/Hud views and default Renderings"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Extend `GameIO`, keep all implementors compiling and tests green
|
||
|
||
This makes `print(StyledText)` the abstract primitive and `write`/`showRoom`/`setHud` defaults, then gives each implementor a minimal `print` so the build stays green. Rich rendering comes in later tasks.
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java`
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java`
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
|
||
- Modify: `src/test/java/thb/jeanluc/adventure/io/TestIO.java`
|
||
|
||
- [ ] **Step 1: Rewrite `GameIO.java`**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io;
|
||
|
||
import thb.jeanluc.adventure.io.text.Hud;
|
||
import thb.jeanluc.adventure.io.text.Renderings;
|
||
import thb.jeanluc.adventure.io.text.RoomView;
|
||
import thb.jeanluc.adventure.io.text.StyledText;
|
||
|
||
/**
|
||
* Bidirectional channel between the engine and the player. Commands emit
|
||
* semantic, role-styled output via {@link #print(StyledText)} (the primitive)
|
||
* and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
|
||
*/
|
||
public interface GameIO {
|
||
|
||
/** Blocks until a line of input is available; empty string at end of input. */
|
||
String readLine();
|
||
|
||
/** Renders one styled output block. The only method implementors must provide. */
|
||
void print(StyledText text);
|
||
|
||
/** Convenience: a plain, unstyled line. */
|
||
default void write(String s) {
|
||
print(StyledText.of(s));
|
||
}
|
||
|
||
/** Renders a room. Default flattens via {@link Renderings}; renderers may override. */
|
||
default void showRoom(RoomView room) {
|
||
print(Renderings.roomToStyledText(room));
|
||
}
|
||
|
||
/** Updates the status/HUD region. No-op by default; renderers may override. */
|
||
default void setHud(Hud hud) {
|
||
// no-op
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Give `ConsoleIO` a minimal `print` (full rendering in Task 4)**
|
||
|
||
Replace the `write` method in `ConsoleIO.java` with:
|
||
```java
|
||
@Override
|
||
public void print(StyledText text) {
|
||
out.println(text.plainText());
|
||
}
|
||
```
|
||
(Remove the old `@Override public void write(String s)` method; the `GameIO` default now covers it.)
|
||
|
||
- [ ] **Step 3: Give `SwingIO` a minimal `print` (full layout in Task 7)**
|
||
|
||
In `SwingIO.java`, replace the `write` method with:
|
||
```java
|
||
@Override
|
||
public void print(StyledText text) {
|
||
SwingUtilities.invokeLater(() -> output.append(text.plainText() + "\n"));
|
||
}
|
||
```
|
||
(Remove the old `@Override public void write(String s)` method.)
|
||
|
||
- [ ] **Step 4: Flatten styled output in `TestIO.java`**
|
||
|
||
Replace the `write` method in `TestIO.java` with the new primitive (and keep the `import`):
|
||
```java
|
||
@Override
|
||
public void print(thb.jeanluc.adventure.io.text.StyledText text) {
|
||
outputs.add(text.plainText());
|
||
}
|
||
```
|
||
(Remove the old `@Override public void write(String s)` method. `showRoom`/`setHud` use the `GameIO` defaults: `showRoom` flows through `print`, so room text is still captured in `outputs`.)
|
||
|
||
- [ ] **Step 5: Run the full suite**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS — all 67 existing tests still green (output now routed through `print`, plain-text content unchanged).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \
|
||
src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \
|
||
src/main/java/thb/jeanluc/adventure/io/SwingIO.java \
|
||
src/test/java/thb/jeanluc/adventure/io/TestIO.java
|
||
git commit -m "feat(io): make print(StyledText) the GameIO primitive; keep suite green"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `ConsoleIO` rich renderer (ANSI + box-drawing + HUD + modes)
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java`
|
||
- Test: `src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java`
|
||
|
||
- [ ] **Step 1: Write the failing test** (colour OFF so assertions are on structure, not escape codes)
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
import thb.jeanluc.adventure.io.text.Hud;
|
||
import thb.jeanluc.adventure.io.text.RoomView;
|
||
import thb.jeanluc.adventure.io.text.StyledText;
|
||
|
||
import java.io.BufferedReader;
|
||
import java.io.ByteArrayOutputStream;
|
||
import java.io.PrintStream;
|
||
import java.io.StringReader;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.List;
|
||
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class ConsoleIORenderTest {
|
||
|
||
private ConsoleIO io(ByteArrayOutputStream sink) {
|
||
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
|
||
return new ConsoleIO(new BufferedReader(new StringReader("")), ps,
|
||
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
|
||
}
|
||
|
||
@Test
|
||
void showRoom_drawsHeadingSectionsAndExits() {
|
||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||
io(sink).showRoom(new RoomView("Kitchen", "A cold kitchen.",
|
||
List.of("Lamp"), List.of("Old Man"), List.of("north", "east")));
|
||
String out = sink.toString(StandardCharsets.UTF_8);
|
||
assertThat(out).contains("KITCHEN");
|
||
assertThat(out).contains("A cold kitchen.");
|
||
assertThat(out).contains("Lamp");
|
||
assertThat(out).contains("Old Man");
|
||
assertThat(out).contains("north").contains("east");
|
||
}
|
||
|
||
@Test
|
||
void print_colorOff_emitsNoAnsiEscapes() {
|
||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||
io(sink).print(StyledText.builder().item("brass lamp").build());
|
||
assertThat(sink.toString(StandardCharsets.UTF_8)).doesNotContain("[");
|
||
}
|
||
|
||
@Test
|
||
void setHud_printsLocationGoldTurnAndLight() {
|
||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||
io(sink).setHud(new Hud("Kitchen", 0, 7, false));
|
||
String out = sink.toString(StandardCharsets.UTF_8);
|
||
assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off");
|
||
}
|
||
|
||
@Test
|
||
void asciiGlyphMode_usesPlusDashFrame() {
|
||
ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
|
||
new ConsoleIO(new BufferedReader(new StringReader("")), ps,
|
||
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.ASCII)
|
||
.showRoom(new RoomView("Cell", "x", List.of(), List.of(), List.of("north")));
|
||
String out = sink.toString(StandardCharsets.UTF_8);
|
||
assertThat(out).contains("+").contains("-");
|
||
assertThat(out).doesNotContain("─");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `mvn -q test -Dtest=ConsoleIORenderTest`
|
||
Expected: FAIL — the 4-arg constructor and `ColorMode`/`GlyphMode` do not exist yet.
|
||
|
||
- [ ] **Step 3: Rewrite `ConsoleIO.java`**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io;
|
||
|
||
import thb.jeanluc.adventure.io.text.Hud;
|
||
import thb.jeanluc.adventure.io.text.RoomView;
|
||
import thb.jeanluc.adventure.io.text.Span;
|
||
import thb.jeanluc.adventure.io.text.Style;
|
||
import thb.jeanluc.adventure.io.text.StyledText;
|
||
|
||
import java.io.BufferedReader;
|
||
import java.io.IOException;
|
||
import java.io.InputStreamReader;
|
||
import java.io.PrintStream;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.List;
|
||
|
||
/**
|
||
* {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
|
||
* (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
|
||
* and degrade safely (no colours when output is piped; ASCII frames on request).
|
||
*/
|
||
public class ConsoleIO implements GameIO {
|
||
|
||
/** Whether ANSI colour is emitted. */
|
||
public enum ColorMode { ON, OFF, AUTO }
|
||
|
||
/** Frame/glyph fidelity tier. */
|
||
public enum GlyphMode { ASCII, UNICODE, GLYPH }
|
||
|
||
private static final int FRAME_WIDTH = 44;
|
||
|
||
private final BufferedReader in;
|
||
private final PrintStream out;
|
||
private final boolean useColor;
|
||
private final GlyphMode glyphs;
|
||
|
||
public ConsoleIO() {
|
||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
|
||
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
|
||
}
|
||
|
||
/** Backward-compatible stream constructor (colour off, unicode frames). */
|
||
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
|
||
}
|
||
|
||
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
|
||
this.in = in;
|
||
this.out = out;
|
||
this.glyphs = glyphs;
|
||
this.useColor = switch (colorMode) {
|
||
case ON -> true;
|
||
case OFF -> false;
|
||
case AUTO -> System.console() != null;
|
||
};
|
||
}
|
||
|
||
@Override
|
||
public String readLine() {
|
||
out.print("> ");
|
||
out.flush();
|
||
try {
|
||
String line = in.readLine();
|
||
return line == null ? "" : line;
|
||
} catch (IOException e) {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void print(StyledText text) {
|
||
StringBuilder sb = new StringBuilder();
|
||
for (Span s : text.spans()) {
|
||
sb.append(paint(s.style(), s.text()));
|
||
}
|
||
out.println(sb);
|
||
}
|
||
|
||
@Override
|
||
public void showRoom(RoomView room) {
|
||
out.println(paint(Style.HEADING, frameTop(room.name())));
|
||
out.println(room.description().stripTrailing());
|
||
if (!room.items().isEmpty()) {
|
||
out.println(section("Items", room.items(), Style.ITEM));
|
||
}
|
||
if (!room.npcs().isEmpty()) {
|
||
out.println(section("People", room.npcs(), Style.NPC));
|
||
}
|
||
if (!room.exits().isEmpty()) {
|
||
out.println(section("Exits", room.exits(), Style.EXIT));
|
||
} else {
|
||
out.println(" " + paint(Style.DIM, "no obvious exits"));
|
||
}
|
||
out.println(paint(Style.HEADING, frameBottom()));
|
||
}
|
||
|
||
@Override
|
||
public void setHud(Hud h) {
|
||
String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn()
|
||
+ " · light: " + (h.lightOn() ? "on" : "off") + " ]";
|
||
out.println(paint(Style.DIM, line));
|
||
}
|
||
|
||
private String section(String label, List<String> xs, Style st) {
|
||
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
||
for (int i = 0; i < xs.size(); i++) {
|
||
if (i > 0) {
|
||
sb.append(" · ");
|
||
}
|
||
sb.append(paint(st, xs.get(i)));
|
||
}
|
||
return sb.toString();
|
||
}
|
||
|
||
private String frameTop(String title) {
|
||
String tl = glyphs == GlyphMode.ASCII ? "+" : "┌";
|
||
String tr = glyphs == GlyphMode.ASCII ? "+" : "┐";
|
||
String h = glyphs == GlyphMode.ASCII ? "-" : "─";
|
||
StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
|
||
.append(title.toUpperCase()).append(' ');
|
||
while (sb.length() < FRAME_WIDTH - 1) {
|
||
sb.append(h);
|
||
}
|
||
return sb.append(tr).toString();
|
||
}
|
||
|
||
private String frameBottom() {
|
||
String bl = glyphs == GlyphMode.ASCII ? "+" : "└";
|
||
String br = glyphs == GlyphMode.ASCII ? "+" : "┘";
|
||
String h = glyphs == GlyphMode.ASCII ? "-" : "─";
|
||
StringBuilder sb = new StringBuilder(bl);
|
||
while (sb.length() < FRAME_WIDTH - 1) {
|
||
sb.append(h);
|
||
}
|
||
return sb.append(br).toString();
|
||
}
|
||
|
||
private String paint(Style st, String s) {
|
||
if (!useColor) {
|
||
return s;
|
||
}
|
||
String code = switch (st) {
|
||
case HEADING -> "[1;35m";
|
||
case ITEM -> "[36m";
|
||
case NPC -> "[33m";
|
||
case EXIT -> "[32m";
|
||
case DANGER -> "[31m";
|
||
case DIM -> "[90m";
|
||
case PLAIN -> "";
|
||
};
|
||
return code.isEmpty() ? s : code + s + "[0m";
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `mvn -q test -Dtest=ConsoleIORenderTest`
|
||
Expected: PASS (4 tests).
|
||
|
||
- [ ] **Step 5: Run the full suite**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS (existing tests + new ones).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \
|
||
src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java
|
||
git commit -m "feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Migrate `LookCommand` to emit a `RoomView`
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java`
|
||
- Test (existing, should stay green): `src/test/java/thb/jeanluc/adventure/command/impl/LookCommandTest.java`
|
||
|
||
- [ ] **Step 1: Rewrite `LookCommand.execute`**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.command.impl;
|
||
|
||
import thb.jeanluc.adventure.command.Command;
|
||
import thb.jeanluc.adventure.game.GameContext;
|
||
import thb.jeanluc.adventure.io.text.RoomView;
|
||
import thb.jeanluc.adventure.model.Direction;
|
||
import thb.jeanluc.adventure.model.Npc;
|
||
import thb.jeanluc.adventure.model.Room;
|
||
import thb.jeanluc.adventure.model.item.Item;
|
||
|
||
import java.util.List;
|
||
|
||
/**
|
||
* Describes the current room by emitting a {@link RoomView}; the active
|
||
* frontend decides how to render it. Usage: {@code look}.
|
||
*/
|
||
public class LookCommand implements Command {
|
||
|
||
@Override
|
||
public void execute(GameContext ctx, List<String> args) {
|
||
Room room = ctx.getPlayer().getCurrentRoom();
|
||
List<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
||
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
||
ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits));
|
||
}
|
||
|
||
@Override
|
||
public String help() {
|
||
return "look - describe the current room";
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the relevant tests**
|
||
|
||
Run: `mvn -q test -Dtest=LookCommandTest`
|
||
Expected: PASS — `TestIO.showRoom` uses the `GameIO` default → `Renderings` → `print`, so `lastOutput()` still contains "Kitchen", "kitchen desc", "Lamp", "Letter", "Old Man", "north".
|
||
|
||
- [ ] **Step 3: Run the full suite**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java
|
||
git commit -m "refactor(command): LookCommand emits semantic RoomView"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Per-turn HUD in `Game`
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java`
|
||
|
||
- [ ] **Step 1: Add a turn counter and HUD update to the loop**
|
||
|
||
In `Game.java`, add the import and a field, and update the HUD after each handled command. Full updated `run()` plus the new field:
|
||
|
||
Add import:
|
||
```java
|
||
import thb.jeanluc.adventure.io.text.Hud;
|
||
```
|
||
|
||
Add field (next to `running`):
|
||
```java
|
||
/** Number of commands processed this session; shown in the HUD. */
|
||
private int turn = 0;
|
||
```
|
||
|
||
Replace `run()` with:
|
||
```java
|
||
public void run() {
|
||
publishHud();
|
||
while (running) {
|
||
String input = ctx.getIo().readLine();
|
||
if (input == null) {
|
||
break;
|
||
}
|
||
ParsedCommand parsed = parser.parse(input);
|
||
if (parsed.verb().isEmpty()) {
|
||
continue;
|
||
}
|
||
Optional<Command> cmd = registry.find(parsed.verb());
|
||
if (cmd.isEmpty()) {
|
||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||
} else {
|
||
cmd.get().execute(ctx, parsed.args());
|
||
turn++;
|
||
}
|
||
publishHud();
|
||
}
|
||
}
|
||
|
||
private void publishHud() {
|
||
ctx.getIo().setHud(new Hud(
|
||
ctx.getPlayer().getCurrentRoom().getName(),
|
||
ctx.getPlayer().getGold(),
|
||
turn,
|
||
false));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the full suite**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS — `GameTest` unaffected (`TestIO.setHud` is the no-op default; HUD output is not asserted).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/game/Game.java
|
||
git commit -m "feat(game): publish HUD (location, gold, turn) each loop iteration"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `SwingIO` 4-region layout, `JTextPane`, bundled font
|
||
|
||
No unit test (Swing/EDT). Verified manually via `mvn exec:java@gui`.
|
||
|
||
**Files:**
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
|
||
- Create: `src/main/resources/fonts/README.md`
|
||
|
||
- [ ] **Step 1: Rewrite `SwingIO.java`**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io;
|
||
|
||
import thb.jeanluc.adventure.io.text.Hud;
|
||
import thb.jeanluc.adventure.io.text.RoomView;
|
||
import thb.jeanluc.adventure.io.text.Span;
|
||
import thb.jeanluc.adventure.io.text.Style;
|
||
import thb.jeanluc.adventure.io.text.StyledText;
|
||
|
||
import javax.swing.BorderFactory;
|
||
import javax.swing.JFrame;
|
||
import javax.swing.JLabel;
|
||
import javax.swing.JScrollPane;
|
||
import javax.swing.JTextArea;
|
||
import javax.swing.JTextField;
|
||
import javax.swing.JTextPane;
|
||
import javax.swing.SwingUtilities;
|
||
import javax.swing.text.BadLocationException;
|
||
import javax.swing.text.SimpleAttributeSet;
|
||
import javax.swing.text.StyleConstants;
|
||
import javax.swing.text.StyledDocument;
|
||
import java.awt.BorderLayout;
|
||
import java.awt.Color;
|
||
import java.awt.Dimension;
|
||
import java.awt.Font;
|
||
import java.awt.GraphicsEnvironment;
|
||
import java.awt.Insets;
|
||
import java.io.InputStream;
|
||
import java.util.ArrayList;
|
||
import java.util.List;
|
||
import java.util.concurrent.LinkedBlockingQueue;
|
||
|
||
/**
|
||
* {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
|
||
* a styled output pane (centre), a side panel for exits / future map (right),
|
||
* and an input field (bottom). A bundled TrueType font is loaded if present so
|
||
* colours and glyphs work without the player installing anything.
|
||
*/
|
||
public class SwingIO implements GameIO {
|
||
|
||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||
private final JFrame frame;
|
||
private final JTextPane output;
|
||
private final StyledDocument doc;
|
||
private final JTextField input;
|
||
private final JLabel hud;
|
||
private final JTextArea side;
|
||
private final Font baseFont;
|
||
|
||
public SwingIO(String title) {
|
||
baseFont = loadFont();
|
||
|
||
output = new JTextPane();
|
||
output.setEditable(false);
|
||
output.setBackground(new Color(0x0b, 0x0e, 0x13));
|
||
output.setForeground(new Color(0xcf, 0xd6, 0xe0));
|
||
output.setFont(baseFont);
|
||
output.setMargin(new Insets(8, 8, 8, 8));
|
||
doc = output.getStyledDocument();
|
||
|
||
hud = new JLabel(" ");
|
||
hud.setOpaque(true);
|
||
hud.setBackground(new Color(0x11, 0x15, 0x1c));
|
||
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
|
||
hud.setFont(baseFont.deriveFont(12f));
|
||
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
|
||
|
||
side = new JTextArea();
|
||
side.setEditable(false);
|
||
side.setBackground(new Color(0x0e, 0x12, 0x18));
|
||
side.setForeground(new Color(0x6f, 0xcf, 0x73));
|
||
side.setFont(baseFont.deriveFont(12f));
|
||
side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
|
||
JScrollPane sideScroll = new JScrollPane(side);
|
||
sideScroll.setPreferredSize(new Dimension(210, 0));
|
||
|
||
input = new JTextField();
|
||
input.setFont(baseFont);
|
||
input.addActionListener(e -> {
|
||
String line = input.getText();
|
||
input.setText("");
|
||
appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
|
||
inputs.offer(line);
|
||
});
|
||
|
||
frame = new JFrame(title);
|
||
frame.setLayout(new BorderLayout());
|
||
frame.add(hud, BorderLayout.NORTH);
|
||
frame.add(new JScrollPane(output), BorderLayout.CENTER);
|
||
frame.add(sideScroll, BorderLayout.EAST);
|
||
frame.add(input, BorderLayout.SOUTH);
|
||
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
||
frame.setSize(900, 600);
|
||
frame.setLocationRelativeTo(null);
|
||
frame.setVisible(true);
|
||
input.requestFocusInWindow();
|
||
}
|
||
|
||
private Font loadFont() {
|
||
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
|
||
if (in != null) {
|
||
Font f = Font.createFont(Font.TRUETYPE_FONT, in).deriveFont(14f);
|
||
GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f);
|
||
return f;
|
||
}
|
||
} catch (Exception e) {
|
||
// fall through to the logical monospaced fallback
|
||
}
|
||
return new Font(Font.MONOSPACED, Font.PLAIN, 14);
|
||
}
|
||
|
||
private Color colorFor(Style s) {
|
||
return switch (s) {
|
||
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
||
case ITEM -> new Color(0x46, 0xc8, 0xd8);
|
||
case NPC -> new Color(0xe6, 0xc3, 0x4a);
|
||
case EXIT -> new Color(0x6f, 0xcf, 0x73);
|
||
case DANGER -> new Color(0xe0, 0x6c, 0x6c);
|
||
case DIM -> new Color(0x6b, 0x72, 0x80);
|
||
case PLAIN -> new Color(0xcf, 0xd6, 0xe0);
|
||
};
|
||
}
|
||
|
||
private void appendSpans(List<Span> spans) {
|
||
SwingUtilities.invokeLater(() -> {
|
||
try {
|
||
for (Span sp : spans) {
|
||
SimpleAttributeSet as = new SimpleAttributeSet();
|
||
StyleConstants.setForeground(as, colorFor(sp.style()));
|
||
if (sp.style() == Style.HEADING) {
|
||
StyleConstants.setBold(as, true);
|
||
}
|
||
doc.insertString(doc.getLength(), sp.text(), as);
|
||
}
|
||
output.setCaretPosition(doc.getLength());
|
||
} catch (BadLocationException ignored) {
|
||
// append-only; cannot occur with getLength()
|
||
}
|
||
});
|
||
}
|
||
|
||
@Override
|
||
public String readLine() {
|
||
try {
|
||
return inputs.take();
|
||
} catch (InterruptedException e) {
|
||
Thread.currentThread().interrupt();
|
||
return "";
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void print(StyledText text) {
|
||
List<Span> spans = new ArrayList<>(text.spans());
|
||
spans.add(new Span("\n", Style.PLAIN));
|
||
appendSpans(spans);
|
||
}
|
||
|
||
@Override
|
||
public void showRoom(RoomView room) {
|
||
List<Span> spans = new ArrayList<>();
|
||
spans.add(new Span(room.name() + "\n", Style.HEADING));
|
||
spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN));
|
||
if (!room.items().isEmpty()) {
|
||
spans.add(new Span("You see ", Style.PLAIN));
|
||
addList(spans, room.items(), Style.ITEM);
|
||
spans.add(new Span(".\n", Style.PLAIN));
|
||
}
|
||
if (!room.npcs().isEmpty()) {
|
||
addList(spans, room.npcs(), Style.NPC);
|
||
spans.add(new Span(" is here.\n", Style.PLAIN));
|
||
}
|
||
appendSpans(spans);
|
||
|
||
String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits());
|
||
SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits));
|
||
}
|
||
|
||
@Override
|
||
public void setHud(Hud h) {
|
||
String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn()
|
||
+ " light " + (h.lightOn() ? "on" : "off");
|
||
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
||
}
|
||
|
||
private void addList(List<Span> spans, List<String> xs, Style st) {
|
||
for (int i = 0; i < xs.size(); i++) {
|
||
if (i > 0) {
|
||
spans.add(new Span(", ", Style.PLAIN));
|
||
}
|
||
spans.add(new Span(xs.get(i), st));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the font drop-in note**
|
||
|
||
`src/main/resources/fonts/README.md`:
|
||
```markdown
|
||
# Bundled GUI font
|
||
|
||
Drop a TrueType font here named `game.ttf` to use it in the Swing GUI
|
||
(`SwingIO`). It is loaded via `Font.createFont` and registered at runtime —
|
||
no installation on the player's machine required.
|
||
|
||
- A monospaced font is recommended (the UI assumes fixed width for frames).
|
||
- A Nerd Font variant additionally provides icon glyphs.
|
||
- Check the font's licence before committing the `.ttf`.
|
||
|
||
If no `game.ttf` is present, `SwingIO` falls back to the logical
|
||
`Font.MONOSPACED`, so the game still runs.
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it compiles and the suite is green**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4: Manual smoke test of the GUI**
|
||
|
||
Run: `mvn -q -DskipTests exec:java@gui`
|
||
Expected: a window opens with a HUD bar on top, styled room text in the centre, an EXITS list on the right, and an input field at the bottom. Typing `look`, `go north`, `take lamp` updates the regions. Close the window to exit.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/io/SwingIO.java \
|
||
src/main/resources/fonts/README.md
|
||
git commit -m "feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Welcome banner + wire-in
|
||
|
||
**Files:**
|
||
- Create: `src/main/java/thb/jeanluc/adventure/io/text/Banner.java`
|
||
- Test: add to `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java` (or a new `BannerTest`)
|
||
- Modify: `src/main/java/thb/jeanluc/adventure/App.java`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java`:
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
import org.junit.jupiter.api.Test;
|
||
import static org.assertj.core.api.Assertions.assertThat;
|
||
|
||
class BannerTest {
|
||
|
||
@Test
|
||
void welcome_containsTitleAsHeading() {
|
||
StyledText b = Banner.welcome("Haunted Manor");
|
||
assertThat(b.plainText()).contains("Haunted Manor");
|
||
boolean hasHeading = b.spans().stream().anyMatch(s -> s.style() == Style.HEADING);
|
||
assertThat(hasHeading).isTrue();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `mvn -q test -Dtest=BannerTest`
|
||
Expected: FAIL — `Banner` does not exist.
|
||
|
||
- [ ] **Step 3: Write `Banner.java`**
|
||
|
||
```java
|
||
package thb.jeanluc.adventure.io.text;
|
||
|
||
/** Builds styled banners for big moments (game start, finale). */
|
||
public final class Banner {
|
||
|
||
private Banner() {
|
||
}
|
||
|
||
/** A framed, heading-styled welcome banner for the given title. */
|
||
public static StyledText welcome(String title) {
|
||
String bar = "=".repeat(Math.max(8, title.length() + 8));
|
||
return StyledText.builder()
|
||
.heading(bar + "\n")
|
||
.heading(" " + title + "\n")
|
||
.heading(bar)
|
||
.build();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `mvn -q test -Dtest=BannerTest`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Use the banner in `App.run`**
|
||
|
||
In `App.java`, add the import:
|
||
```java
|
||
import thb.jeanluc.adventure.io.text.Banner;
|
||
```
|
||
Replace these two lines in `run(GameIO io)`:
|
||
```java
|
||
io.write(loaded.world().getTitle());
|
||
io.write(loaded.world().getWelcomeMessage());
|
||
```
|
||
with:
|
||
```java
|
||
io.print(Banner.welcome(loaded.world().getTitle()));
|
||
io.write(loaded.world().getWelcomeMessage());
|
||
```
|
||
|
||
- [ ] **Step 6: Run the full suite**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Final verification (both frontends)**
|
||
|
||
Run: `mvn -q test`
|
||
Expected: PASS — full suite green.
|
||
|
||
Run: `printf 'look\ngo north\nquit\n' | mvn -q -DskipTests exec:java@run`
|
||
Expected: framed welcome banner, a boxed room view with coloured/ANSI sections (or plain when piped), HUD line, no exceptions.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/main/java/thb/jeanluc/adventure/io/text/Banner.java \
|
||
src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java \
|
||
src/main/java/thb/jeanluc/adventure/App.java
|
||
git commit -m "feat(io): welcome banner for game start"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review notes
|
||
|
||
- **Spec coverage:** model (T1), views + default rendering (T2), GameIO extension + migration (T3), ConsoleIO B-baseline + modes + glyph fallback (T4), command migration (T5), HUD (T6), SwingIO region layout + bundled font + nerd-font solution (T7), ASCII-art banner mechanism (T8). Map/menu/save/music explicitly deferred — side panel slot delivered in T7.
|
||
- **Type consistency:** `print(StyledText)`, `showRoom(RoomView)`, `setHud(Hud)`, `StyledText.builder()/of()/spans()/plainText()`, `Renderings.roomToStyledText`, `ConsoleIO.ColorMode/GlyphMode`, `Banner.welcome` are used consistently across tasks.
|
||
- **Open spec detail:** `Hud.lightOn` is wired as constant `false` (T6) until the light mechanic arrives with content — matches spec §11.
|