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