40 KiB
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
File Structure
Create:
src/main/java/thb/jeanluc/adventure/io/text/Style.java— role enumsrc/main/java/thb/jeanluc/adventure/io/text/Span.java— text+style recordsrc/main/java/thb/jeanluc/adventure/io/text/StyledText.java— span list + fluent buildersrc/main/java/thb/jeanluc/adventure/io/text/RoomView.java— semantic room viewsrc/main/java/thb/jeanluc/adventure/io/text/Hud.java— status snapshotsrc/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 StyledTextsrc/main/resources/fonts/README.md— drop-in instructions for a bundled.ttfsrc/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.javasrc/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.javasrc/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java
Modify:
src/main/java/thb/jeanluc/adventure/io/GameIO.java— addprint/showRoom/setHud,writedefaultsrc/main/java/thb/jeanluc/adventure/io/ConsoleIO.java— render ANSI + box-drawing + HUD; color/glyph modessrc/main/java/thb/jeanluc/adventure/io/SwingIO.java— 4-region layout,JTextPane, bundled fontsrc/test/java/thb/jeanluc/adventure/io/TestIO.java— flatten styled output to plain textsrc/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java— emitRoomViewsrc/main/java/thb/jeanluc/adventure/game/Game.java— per-turn HUD updatesrc/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
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:
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:
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:
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
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
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:
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:
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:
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
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
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
ConsoleIOa minimalprint(full rendering in Task 4)
Replace the write method in ConsoleIO.java with:
@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
SwingIOa minimalprint(full layout in Task 7)
In SwingIO.java, replace the write method with:
@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):
@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
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)
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
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
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
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
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:
import thb.jeanluc.adventure.io.text.Hud;
Add field (next to running):
/** Number of commands processed this session; shown in the HUD. */
private int turn = 0;
Replace run() with:
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
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
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:
# 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
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 newBannerTest) -
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:
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
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:
import thb.jeanluc.adventure.io.text.Banner;
Replace these two lines in run(GameIO io):
io.write(loaded.world().getTitle());
io.write(loaded.world().getWelcomeMessage());
with:
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
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.welcomeare used consistently across tasks. - Open spec detail:
Hud.lightOnis wired as constantfalse(T6) until the light mechanic arrives with content — matches spec §11.