# Quests & Quest-Log 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:** Condition-driven multi-stage quests with a per-turn engine, plus a console `quests` command and a GUI quest-box under the map. **Architecture:** `Quest`/`QuestStage` are data layered on the foundation (stage completion = `Condition`s, rewards = `Effect`s). `QuestLog` (in `GameContext`) holds progress; `QuestEngine.tick(ctx)` auto-advances active quests each turn and announces; a `START_QUEST` effect begins quests. `QuestView` is pushed/rendered exactly like the map. **Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Swing. Spec: [docs/superpowers/specs/2026-05-31-quests-design.md](../specs/2026-05-31-quests-design.md) --- ## Task 1: Quest model, QuestLog, START_QUEST effect **Files:** - Create: `model/Quest.java`, `model/QuestStage.java`, `game/QuestLog.java` - Modify: `model/Effect.java`, `game/Effects.java`, `game/GameContext.java` - Test: `game/QuestLogTest.java`, add a case to `game/EffectsTest.java` - [ ] **Step 1: Write failing tests** `src/test/java/thb/jeanluc/adventure/game/QuestLogTest.java`: ```java package thb.jeanluc.adventure.game; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class QuestLogTest { @Test void startAdvanceComplete() { QuestLog log = new QuestLog(); assertThat(log.isActive("q")).isFalse(); log.start("q"); assertThat(log.isActive("q")).isTrue(); assertThat(log.stageIndex("q")).isEqualTo(0); log.advance("q"); assertThat(log.stageIndex("q")).isEqualTo(1); log.complete("q"); assertThat(log.isActive("q")).isFalse(); assertThat(log.isCompleted("q")).isTrue(); } @Test void startIgnoredOnceCompleted() { QuestLog log = new QuestLog(); log.start("q"); log.complete("q"); log.start("q"); assertThat(log.isActive("q")).isFalse(); assertThat(log.isCompleted("q")).isTrue(); } } ``` Add to `EffectsTest` (new import `thb.jeanluc.adventure.model.Effect` already present): ```java @Test void startQuestStartsQuestInLog() { GameContext ctx = ctx(java.util.Map.of()); Effects.apply(new Effect(Effect.Type.START_QUEST, "restore_power"), ctx); assertThat(ctx.getQuestLog().isActive("restore_power")).isTrue(); } ``` - [ ] **Step 2: Run to verify failure** Run: `mvn -q test -Dtest=QuestLogTest,EffectsTest` Expected: FAIL — `QuestLog`, `START_QUEST`, `getQuestLog` missing. - [ ] **Step 3: Create `model/Quest.java` and `model/QuestStage.java`** ```java package thb.jeanluc.adventure.model; import java.util.List; /** A multi-stage quest. Stage completion is condition-driven; rewards are effects. */ public record Quest(String id, String title, boolean autoStart, List stages, List onComplete) { public Quest { stages = stages == null ? List.of() : List.copyOf(stages); onComplete = onComplete == null ? List.of() : List.copyOf(onComplete); } } ``` ```java package thb.jeanluc.adventure.model; import java.util.List; /** One stage of a {@link Quest}: an objective, a completion condition, optional rewards. */ public record QuestStage(String objective, List completion, List onComplete) { public QuestStage { completion = completion == null ? List.of() : List.copyOf(completion); onComplete = onComplete == null ? List.of() : List.copyOf(onComplete); } } ``` - [ ] **Step 4: Create `game/QuestLog.java`** ```java package thb.jeanluc.adventure.game; import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; /** Runtime quest progress: current stage per active quest, plus completed ids. */ public class QuestLog { private final Map stageIndex = new LinkedHashMap<>(); private final Set completed = new LinkedHashSet<>(); public void start(String id) { if (!completed.contains(id)) { stageIndex.putIfAbsent(id, 0); } } public boolean isActive(String id) { return stageIndex.containsKey(id); } public boolean isCompleted(String id) { return completed.contains(id); } public int stageIndex(String id) { return stageIndex.getOrDefault(id, 0); } public void advance(String id) { stageIndex.merge(id, 1, Integer::sum); } public void complete(String id) { stageIndex.remove(id); completed.add(id); } public Set active() { return Collections.unmodifiableSet(stageIndex.keySet()); } public Set completed() { return Collections.unmodifiableSet(completed); } } ``` - [ ] **Step 5: Add `START_QUEST` to `Effect.java` and handle it in `Effects.java`** In `model/Effect.java`, extend the enum: ```java public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY, START_QUEST } ``` In `game/Effects.java`, add a case in `apply`: ```java case START_QUEST -> ctx.getQuestLog().start(e.arg()); ``` - [ ] **Step 6: Add `QuestLog` to `GameContext.java`** Add after the `state` field: ```java /** Runtime quest progress. Created fresh per context; not a constructor arg. */ private final QuestLog questLog = new QuestLog(); ``` - [ ] **Step 7: Run tests** Run: `mvn -q test -Dtest=QuestLogTest,EffectsTest` Expected: PASS. Run: `mvn -q test` Expected: PASS (additive). - [ ] **Step 8: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/model/Quest.java \ src/main/java/thb/jeanluc/adventure/model/QuestStage.java \ src/main/java/thb/jeanluc/adventure/game/QuestLog.java \ src/main/java/thb/jeanluc/adventure/model/Effect.java \ src/main/java/thb/jeanluc/adventure/game/Effects.java \ src/main/java/thb/jeanluc/adventure/game/GameContext.java \ src/test/java/thb/jeanluc/adventure/game/QuestLogTest.java \ src/test/java/thb/jeanluc/adventure/game/EffectsTest.java git commit -m "feat(game): Quest model, QuestLog, START_QUEST effect" ``` --- ## Task 2: QuestView model + QuestEngine **Files:** - Create: `io/text/QuestEntry.java`, `io/text/QuestView.java`, `game/QuestEngine.java` - Modify: `model/World.java` (add `quests` + back-compat constructor) - Test: `game/QuestEngineTest.java` - [ ] **Step 1: Add `quests` to `World.java`** Add import: ```java import thb.jeanluc.adventure.model.Quest; ``` Add the field after `npcs`: ```java /** Global lookup of quests by id. */ private final Map quests; ``` Add a back-compat constructor (delegates with an empty quest map) below the fields: ```java /** Backward-compatible constructor for worlds without quests. */ public World(Map rooms, Map items, Map npcs, String title, String welcomeMessage) { this(rooms, items, npcs, title, welcomeMessage, Map.of()); } ``` - [ ] **Step 2: Create the view records** `io/text/QuestEntry.java`: ```java package thb.jeanluc.adventure.io.text; /** An active quest's title and its current objective. */ public record QuestEntry(String title, String objective) {} ``` `io/text/QuestView.java`: ```java package thb.jeanluc.adventure.io.text; import java.util.List; /** Frontend-agnostic snapshot of the quest log. */ public record QuestView(List active, List completed) { public QuestView { active = List.copyOf(active); completed = List.copyOf(completed); } } ``` - [ ] **Step 3: Write failing test** `src/test/java/thb/jeanluc/adventure/game/QuestEngineTest.java`: ```java package thb.jeanluc.adventure.game; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.io.text.QuestView; import thb.jeanluc.adventure.model.Condition; import thb.jeanluc.adventure.model.Effect; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Quest; import thb.jeanluc.adventure.model.QuestStage; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class QuestEngineTest { private GameContext ctx(Quest quest) { Player p = new Player(new Room("k", "K", "d"), 0); World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(quest.id(), quest)); return new GameContext(w, p, new TestIO()); } private Quest twoStage() { return new Quest("q", "Quest", true, List.of( new QuestStage("Stage one", List.of(new Condition(Condition.Type.FLAG, "a")), List.of()), new QuestStage("Stage two", List.of(new Condition(Condition.Type.FLAG, "b")), List.of(new Effect(Effect.Type.SET_FLAG, "done")))), List.of()); } @Test void autoStartsAndShowsFirstObjective() { GameContext ctx = ctx(twoStage()); QuestEngine.tick(ctx); QuestView v = QuestEngine.viewOf(ctx); assertThat(v.active()).hasSize(1); assertThat(v.active().getFirst().objective()).isEqualTo("Stage one"); } @Test void advancesWhenConditionHolds() { GameContext ctx = ctx(twoStage()); QuestEngine.tick(ctx); ctx.getState().set("a"); QuestEngine.tick(ctx); assertThat(QuestEngine.viewOf(ctx).active().getFirst().objective()).isEqualTo("Stage two"); assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Objective complete"); } @Test void completesAfterLastStageAndAppliesReward() { GameContext ctx = ctx(twoStage()); ctx.getState().set("a"); ctx.getState().set("b"); QuestEngine.tick(ctx); // cascades through both stages in one tick QuestView v = QuestEngine.viewOf(ctx); assertThat(v.active()).isEmpty(); assertThat(v.completed()).containsExactly("Quest"); assertThat(ctx.getState().isSet("done")).isTrue(); } } ``` - [ ] **Step 4: Run to verify failure** Run: `mvn -q test -Dtest=QuestEngineTest` Expected: FAIL — `QuestEngine` missing. - [ ] **Step 5: Create `game/QuestEngine.java`** ```java package thb.jeanluc.adventure.game; import thb.jeanluc.adventure.io.text.QuestEntry; import thb.jeanluc.adventure.io.text.QuestView; import thb.jeanluc.adventure.io.text.StyledText; import thb.jeanluc.adventure.model.Quest; import thb.jeanluc.adventure.model.QuestStage; import java.util.ArrayList; import java.util.List; import java.util.Map; /** Per-turn quest progression and view building, driven by world conditions. */ public final class QuestEngine { private QuestEngine() { } public static void tick(GameContext ctx) { Map quests = ctx.getWorld().getQuests(); for (Quest q : quests.values()) { if (q.autoStart() && !ctx.getQuestLog().isActive(q.id()) && !ctx.getQuestLog().isCompleted(q.id())) { ctx.getQuestLog().start(q.id()); } } int guard = 0; int max = totalStages(quests) + quests.size() + 1; boolean changed = true; while (changed && guard++ < max) { changed = false; for (String id : new ArrayList<>(ctx.getQuestLog().active())) { Quest q = quests.get(id); if (q == null) { continue; } int idx = ctx.getQuestLog().stageIndex(id); if (idx >= q.stages().size()) { finish(ctx, q); changed = true; continue; } QuestStage stage = q.stages().get(idx); if (Conditions.all(stage.completion(), ctx)) { Effects.applyAll(stage.onComplete(), ctx); ctx.getIo().print(StyledText.builder() .heading("✓ Objective complete: ").plain(stage.objective()).build()); ctx.getQuestLog().advance(id); if (ctx.getQuestLog().stageIndex(id) >= q.stages().size()) { finish(ctx, q); } changed = true; } } } } private static void finish(GameContext ctx, Quest q) { if (ctx.getQuestLog().isCompleted(q.id())) { return; } Effects.applyAll(q.onComplete(), ctx); ctx.getQuestLog().complete(q.id()); ctx.getIo().print(StyledText.builder() .heading("★ Quest complete: ").plain(q.title()).build()); } public static QuestView viewOf(GameContext ctx) { Map quests = ctx.getWorld().getQuests(); List active = new ArrayList<>(); for (String id : ctx.getQuestLog().active()) { Quest q = quests.get(id); if (q == null) { continue; } int idx = ctx.getQuestLog().stageIndex(id); String objective = idx < q.stages().size() ? q.stages().get(idx).objective() : ""; active.add(new QuestEntry(q.title(), objective)); } List completed = new ArrayList<>(); for (String id : ctx.getQuestLog().completed()) { Quest q = quests.get(id); completed.add(q == null ? id : q.title()); } return new QuestView(active, completed); } private static int totalStages(Map quests) { int total = 0; for (Quest q : quests.values()) { total += q.stages().size(); } return total; } } ``` - [ ] **Step 6: Run tests** Run: `mvn -q test -Dtest=QuestEngineTest` Expected: PASS. Run: `mvn -q test` Expected: PASS (the `World` back-compat constructor keeps existing 5-arg callers working). - [ ] **Step 7: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/io/text/QuestEntry.java \ src/main/java/thb/jeanluc/adventure/io/text/QuestView.java \ src/main/java/thb/jeanluc/adventure/game/QuestEngine.java \ src/main/java/thb/jeanluc/adventure/model/World.java \ src/test/java/thb/jeanluc/adventure/game/QuestEngineTest.java git commit -m "feat(game): QuestEngine (auto-advance + announcements) and QuestView" ``` --- ## Task 3: Loading — QuestDto, QuestFactory, quests.yaml, EffectDto.startQuest **Files:** - Create: `loader/dto/QuestDto.java`, `loader/dto/QuestStageDto.java`, `loader/QuestFactory.java` - Modify: `loader/dto/EffectDto.java`, `loader/WorldLoader.java` - Test: `loader/QuestLoadingTest.java` - [ ] **Step 1: Add `startQuest` to `EffectDto.java`** Append a component and add a back-compat constructor: ```java public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say, String startQuest) { /** Backward-compatible constructor without startQuest. */ public EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) { this(setFlag, clearFlag, giveItem, removeItem, say, null); } ``` And add to `toModel()` (before the throw): ```java if (startQuest != null) { return new Effect(Effect.Type.START_QUEST, startQuest); } ``` - [ ] **Step 2: Create the quest DTOs** `loader/dto/QuestStageDto.java`: ```java package thb.jeanluc.adventure.loader.dto; import java.util.List; /** YAML representation of a quest stage. */ public record QuestStageDto(String objective, List completion, List onComplete) { } ``` `loader/dto/QuestDto.java`: ```java package thb.jeanluc.adventure.loader.dto; import java.util.List; /** YAML representation of a quest. */ public record QuestDto(String id, String title, Boolean autoStart, List stages, List onComplete) { } ``` - [ ] **Step 3: Create `loader/QuestFactory.java`** ```java package thb.jeanluc.adventure.loader; import thb.jeanluc.adventure.loader.dto.ConditionDto; import thb.jeanluc.adventure.loader.dto.EffectDto; import thb.jeanluc.adventure.loader.dto.QuestДto; // placeholder, replaced below ``` (Use this exact body — note the correct import names:) ```java package thb.jeanluc.adventure.loader; import thb.jeanluc.adventure.loader.dto.ConditionDto; import thb.jeanluc.adventure.loader.dto.EffectDto; import thb.jeanluc.adventure.loader.dto.QuestDto; import thb.jeanluc.adventure.loader.dto.QuestStageDto; import thb.jeanluc.adventure.model.Quest; import thb.jeanluc.adventure.model.QuestStage; import java.util.ArrayList; import java.util.List; /** Builds {@link Quest} objects from {@link QuestDto}s (no reference resolution needed). */ public final class QuestFactory { private QuestFactory() { } public static Quest fromDto(QuestDto dto) { List stages = new ArrayList<>(); if (dto.stages() != null) { for (QuestStageDto s : dto.stages()) { stages.add(new QuestStage( s.objective(), ConditionDto.toModelList(s.completion()), EffectDto.toModelList(s.onComplete()))); } } return new Quest( dto.id(), dto.title(), Boolean.TRUE.equals(dto.autoStart()), stages, EffectDto.toModelList(dto.onComplete())); } } ``` - [ ] **Step 4: Load `quests.yaml` in `WorldLoader.java`** Add imports: ```java import thb.jeanluc.adventure.loader.dto.QuestDto; import thb.jeanluc.adventure.model.Quest; ``` Add an optional-read helper (next to `readList`): ```java private List readListOptional(String resource, Class elementType) { try (InputStream in = getClass().getResourceAsStream(resource)) { if (in == null) { return List.of(); } CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType); List result = yaml.readValue(in, type); return result == null ? List.of() : result; } catch (IOException e) { throw new WorldLoadException("Failed to parse " + resource, e); } } ``` In `load()`, after reading the room DTOs, add: ```java List questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class); ``` After the existing `requireUniqueIds(...)` calls, add: ```java requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList()); ``` Build the quest map (next to the rooms map build): ```java Map quests = new HashMap<>(); for (QuestDto dto : questDtos) { quests.put(dto.id(), QuestFactory.fromDto(dto)); } ``` Change the `World` construction to pass quests: ```java World world = new World(rooms, items, npcs, gameDto.title(), gameDto.welcomeMessage(), quests); ``` - [ ] **Step 5: Write the loading test** `src/test/java/thb/jeanluc/adventure/loader/QuestLoadingTest.java`: ```java package thb.jeanluc.adventure.loader; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.loader.dto.ConditionDto; import thb.jeanluc.adventure.loader.dto.EffectDto; import thb.jeanluc.adventure.loader.dto.QuestDto; import thb.jeanluc.adventure.loader.dto.QuestStageDto; import thb.jeanluc.adventure.model.Effect; import thb.jeanluc.adventure.model.Quest; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class QuestLoadingTest { @Test void factoryMapsStagesConditionsAndEffects() { QuestDto dto = new QuestDto("q", "Title", true, List.of(new QuestStageDto("obj", List.of(new ConditionDto("power_on", null, null)), List.of(new EffectDto("done", null, null, null, null)))), List.of()); Quest q = QuestFactory.fromDto(dto); assertThat(q.autoStart()).isTrue(); assertThat(q.stages()).hasSize(1); assertThat(q.stages().getFirst().objective()).isEqualTo("obj"); assertThat(q.stages().getFirst().completion()).hasSize(1); assertThat(q.stages().getFirst().onComplete().getFirst().type()).isEqualTo(Effect.Type.SET_FLAG); } @Test void startQuestEffectMaps() { assertThat(new EffectDto(null, null, null, null, null, "q").toModel().type()) .isEqualTo(Effect.Type.START_QUEST); } } ``` - [ ] **Step 6: Run tests** Run: `mvn -q test -Dtest=QuestLoadingTest,ConditionEffectDtoTest` Expected: PASS. Run: `mvn -q test` Expected: PASS — the real `quests.yaml` does not exist yet, so `readListOptional` returns empty; the app still loads. - [ ] **Step 7: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/loader/dto/QuestDto.java \ src/main/java/thb/jeanluc/adventure/loader/dto/QuestStageDto.java \ src/main/java/thb/jeanluc/adventure/loader/QuestFactory.java \ src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java \ src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \ src/test/java/thb/jeanluc/adventure/loader/QuestLoadingTest.java git commit -m "feat(loader): quests.yaml loading + startQuest effect mapping" ``` --- ## Task 4: Console display — QuestText, GameIO methods, command, Game tick **Files:** - Create: `io/text/QuestText.java`, `command/impl/QuestsCommand.java` - Modify: `io/GameIO.java`, `game/Game.java`, `App.java` - Test: `command/impl/QuestsCommandTest.java` - [ ] **Step 1: Create `io/text/QuestText.java`** ```java package thb.jeanluc.adventure.io.text; /** Renders a {@link QuestView} as styled text for the console quest log. */ public final class QuestText { private QuestText() { } public static StyledText render(QuestView view) { if (view.active().isEmpty() && view.completed().isEmpty()) { return StyledText.of("You have no quests yet."); } StyledText.Builder b = StyledText.builder(); b.heading("QUESTS"); if (view.active().isEmpty()) { b.plain("\n").dim(" (none active)"); } for (QuestEntry e : view.active()) { b.plain("\n").heading(e.title()).plain("\n").exit(" -> ").plain(e.objective()); } if (!view.completed().isEmpty()) { b.plain("\n\n").dim("Completed:"); for (String t : view.completed()) { b.plain("\n").dim(" ✓ " + t); } } return b.build(); } } ``` - [ ] **Step 2: Add quest methods to `GameIO.java`** Add imports: ```java import thb.jeanluc.adventure.io.text.QuestText; import thb.jeanluc.adventure.io.text.QuestView; ``` Add after the map methods: ```java /** Per-turn push of the quest log. GUI repaints its box; console ignores. */ default void setQuests(QuestView view) { // no-op } /** On-demand quest log (the 'quests' command). Default renders styled text. */ default void showQuests(QuestView view) { print(QuestText.render(view)); } ``` - [ ] **Step 3: Tick quests + push the view from `Game.java`** Add imports: ```java import thb.jeanluc.adventure.game.QuestEngine; // same package; drop if redundant ``` (Note: `QuestEngine` is in package `game`, same as `Game` — no import needed.) In `publishHud()`, add `QuestEngine.tick(ctx)` as the first line and push the view at the end: ```java private void publishHud() { QuestEngine.tick(ctx); ctx.getIo().setHud(new Hud( ctx.getPlayer().getCurrentRoom().getName(), ctx.getPlayer().getGold(), turn, false)); MapView map = MapLayout.compute( ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), ctx.getPlayer().getCurrentRoom()); ctx.getIo().setMap(map); ctx.getIo().setQuests(QuestEngine.viewOf(ctx)); } ``` - [ ] **Step 4: Create `command/impl/QuestsCommand.java`** ```java package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.QuestEngine; import java.util.List; /** Shows the active and completed quests. Usage: {@code quests}. */ public class QuestsCommand implements Command { @Override public void execute(GameContext ctx, List args) { ctx.getIo().showQuests(QuestEngine.viewOf(ctx)); } @Override public String help() { return "quests - show your active and completed quests (alias: log, journal)"; } } ``` - [ ] **Step 5: Register the command in `App.java`** After the `MapCommand` registration, add: ```java registry.register(new QuestsCommand(), "quests", "log", "journal"); ``` Add the import: ```java import thb.jeanluc.adventure.command.impl.QuestsCommand; ``` - [ ] **Step 6: Write the command test** `src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.java`: ```java package thb.jeanluc.adventure.command.impl; import org.junit.jupiter.api.Test; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.QuestEngine; import thb.jeanluc.adventure.io.TestIO; import thb.jeanluc.adventure.model.Condition; import thb.jeanluc.adventure.model.Player; import thb.jeanluc.adventure.model.Quest; import thb.jeanluc.adventure.model.QuestStage; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; import java.util.List; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class QuestsCommandTest { @Test void showsActiveObjective() { Quest q = new Quest("q", "My Quest", true, List.of(new QuestStage("Do the thing", List.of(new Condition(Condition.Type.FLAG, "x")), List.of())), List.of()); World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of("q", q)); GameContext ctx = new GameContext(w, new Player(new Room("k", "K", "d"), 0), new TestIO()); QuestEngine.tick(ctx); // auto-start new QuestsCommand().execute(ctx, List.of()); String out = ((TestIO) ctx.getIo()).allOutput(); assertThat(out).contains("My Quest").contains("Do the thing"); } } ``` - [ ] **Step 7: Run tests** Run: `mvn -q test -Dtest=QuestsCommandTest` Expected: PASS. Run: `mvn -q test` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/io/text/QuestText.java \ src/main/java/thb/jeanluc/adventure/command/impl/QuestsCommand.java \ src/main/java/thb/jeanluc/adventure/io/GameIO.java \ src/main/java/thb/jeanluc/adventure/game/Game.java \ src/main/java/thb/jeanluc/adventure/App.java \ src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.java git commit -m "feat(io,command): quests command, QuestText, per-turn quest tick" ``` --- ## Task 5: GUI quest-box under the map No unit test (Swing/EDT). Verified via `exec:java@gui`. **Files:** - Create: `io/QuestPanel.java` - Modify: `io/SwingIO.java` - [ ] **Step 1: Create `io/QuestPanel.java`** ```java package thb.jeanluc.adventure.io; import thb.jeanluc.adventure.io.text.QuestText; import thb.jeanluc.adventure.io.text.QuestView; import thb.jeanluc.adventure.io.text.Span; import thb.jeanluc.adventure.io.text.Style; 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.Color; import java.awt.Font; import java.awt.Insets; /** Read-only styled view of the quest log, shown under the map. */ public class QuestPanel extends JTextPane { public QuestPanel(Font font) { setEditable(false); setBackground(new Color(0x0b, 0x0e, 0x13)); setForeground(new Color(0xcf, 0xd6, 0xe0)); setFont(font.deriveFont(12f)); setMargin(new Insets(8, 10, 8, 10)); } public void show(QuestView view) { SwingUtilities.invokeLater(() -> { StyledDocument doc = getStyledDocument(); try { doc.remove(0, doc.getLength()); for (Span sp : QuestText.render(view).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); } setCaretPosition(0); } catch (BadLocationException ignored) { // replace-only; cannot occur with getLength() } }); } 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); }; } } ``` - [ ] **Step 2: Restructure the side region in `SwingIO.java`** (a) Add imports: ```java import javax.swing.JPanel; import thb.jeanluc.adventure.io.text.QuestView; ``` (b) Add fields next to `map` / `sideScroll`: ```java private final QuestPanel quests; private final JScrollPane questScroll; private final JPanel sidePanel; ``` (c) Replace the side-panel construction (where `map` and `sideScroll` are created and `sideScroll` is added EAST). Find: ```java map = new MapPanel(baseFont); sideScroll = new JScrollPane(map); sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18)); ``` and replace with: ```java map = new MapPanel(baseFont); sideScroll = new JScrollPane(map); sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18)); quests = new QuestPanel(baseFont); questScroll = new JScrollPane(quests); questScroll.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(0x2a, 0x2f, 0x3a))); sidePanel = new JPanel(new BorderLayout()); sidePanel.add(sideScroll, BorderLayout.CENTER); sidePanel.add(questScroll, BorderLayout.SOUTH); ``` (d) Change the frame to add `sidePanel` instead of `sideScroll`. Find: ```java frame.add(sideScroll, BorderLayout.EAST); ``` and replace with: ```java frame.add(sidePanel, BorderLayout.EAST); ``` (d') In `updateSideWidth()`, size `sidePanel` (not `sideScroll`) and give the quest box a share of the height. Replace the body of `updateSideWidth()`: ```java int min = (int) (SIDE_MIN * uiScale); int max = (int) (SIDE_MAX * uiScale); int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO))); sidePanel.setPreferredSize(new Dimension(target, 0)); questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32))); sidePanel.revalidate(); frame.validate(); map.revalidate(); map.repaint(); ``` (e) In `applyFonts()`, set the quest font (after the `map.setZoom` line): ```java quests.setFont(baseFont.deriveFont(small)); ``` (f) Override `setQuests` (next to `setMap`): ```java @Override public void setQuests(QuestView view) { quests.show(view); } @Override public void showQuests(QuestView view) { // The GUI quest box is always visible; nothing extra to do. } ``` - [ ] **Step 3: Build and run the suite** Run: `mvn -q test` Expected: PASS. - [ ] **Step 4: Manual GUI smoke** Run: `mvn -q -DskipTests exec:java@gui` Expected: the right side shows the map on top and a "QUESTS" box beneath it listing the active objective. Using the generator and collecting the key updates the objective and shows the completion message in the main pane. Close to exit. - [ ] **Step 5: Commit** ```bash git add src/main/java/thb/jeanluc/adventure/io/QuestPanel.java \ src/main/java/thb/jeanluc/adventure/io/SwingIO.java git commit -m "feat(io): GUI quest-box under the map" ``` --- ## Task 6: Demo quest content + docs + final verification **Files:** - Create: `src/main/resources/world/quests.yaml` - Modify: `README.md`, `docs/enhancement-ideas.md` - [ ] **Step 1: Create `src/main/resources/world/quests.yaml`** ```yaml - id: restore_power title: "Bring the Manor to Life" autoStart: true stages: - objective: "Get the power running." completion: [{ flag: power_on }] - objective: "Earn the Old Man's brass key." completion: [{ hasItem: key }] onComplete: - { setFlag: manor_secured } ``` - [ ] **Step 2: Document the `quests` command in `README.md`** Add a row after the `map` row: ```markdown | `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen | ``` - [ ] **Step 3: Mark 3.2 done in `docs/enhancement-ideas.md`** Under the `### 5. Quest-System (NPC-Ausbau)` status block, append: ```markdown > ✅ #3.2 umgesetzt (Branch `feature/quests`): condition-driven Quests > (Quest/QuestStage), QuestEngine mit Auto-Advance + Ansagen, START_QUEST-Effekt, > Konsolen-Befehl `quests` und GUI-Quest-Box unter der Karte. Demo-Quest > `restore_power`. Offen: #3.3 Win-Condition/Enden. ``` - [ ] **Step 4: Full suite + console end-to-end** Run: `mvn -q test` Expected: PASS. Run: ```bash printf 'quests\ngo south\nuse generator\nquests\ngo north\ntake lamp\ngive lamp old_man\nquests\nquit\n' | mvn -q -DskipTests exec:java@run ``` Expected: the first `quests` shows objective "Get the power running."; after `use generator`, the next `quests` (and the announcement) show the objective advanced to "Earn the Old Man's brass key."; after giving the lamp (which yields the key), the final `quests` shows the quest completed. No exceptions. - [ ] **Step 5: Commit** ```bash git add src/main/resources/world/quests.yaml README.md docs/enhancement-ideas.md git commit -m "feat(content): demo quest 'restore_power'; document quests command" ``` --- ## Self-Review notes - **Spec coverage:** model+log+effect (T1), engine+view+World.quests (T2), loader (T3), console display+command+tick (T4), GUI quest-box (T5), demo+docs (T6). Win/endings (3.3), branching/failure deferred. - **Backward compatibility:** `World` gains a 5-arg back-compat constructor; `EffectDto` gains a 5-arg back-compat constructor; `GameContext` self-initialises `QuestLog`; `setQuests`/`showQuests` are defaulted. Existing tests and YAML untouched. - **Type consistency:** `QuestEngine.tick/viewOf(ctx)`, `QuestLog.start/advance/complete/isActive/isCompleted/stageIndex/active/completed`, `Quest.stages/onComplete`, `QuestStage.objective/completion/onComplete`, `QuestView(active, completed)`, `QuestEntry(title, objective)`, `Effect.Type.START_QUEST`, `EffectDto.startQuest`, `World.getQuests` used consistently. - **Layering:** `Quest`/`QuestStage` (model) carry no `game` dependency; `QuestEngine`/`QuestLog` (game) depend on model + io.text views. `QuestText`/`QuestView` in io.text are renderer-agnostic.