Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-05-31-quests.md
2026-05-31 22:24:20 +02:00

35 KiB

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 = Conditions, rewards = Effects). 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


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:

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):

    @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
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<QuestStage> stages, List<Effect> onComplete) {
    public Quest {
        stages = stages == null ? List.of() : List.copyOf(stages);
        onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
    }
}
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<Condition> completion, List<Effect> 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
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<String, Integer> stageIndex = new LinkedHashMap<>();
    private final Set<String> 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<String> active() {
        return Collections.unmodifiableSet(stageIndex.keySet());
    }

    public Set<String> 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:

    public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY, START_QUEST }

In game/Effects.java, add a case in apply:

            case START_QUEST -> ctx.getQuestLog().start(e.arg());
  • Step 6: Add QuestLog to GameContext.java

Add after the state field:

    /** 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
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:

import thb.jeanluc.adventure.model.Quest;

Add the field after npcs:

    /** Global lookup of quests by id. */
    private final Map<String, Quest> quests;

Add a back-compat constructor (delegates with an empty quest map) below the fields:

    /** Backward-compatible constructor for worlds without quests. */
    public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
                 String title, String welcomeMessage) {
        this(rooms, items, npcs, title, welcomeMessage, Map.of());
    }
  • Step 2: Create the view records

io/text/QuestEntry.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:

package thb.jeanluc.adventure.io.text;

import java.util.List;

/** Frontend-agnostic snapshot of the quest log. */
public record QuestView(List<QuestEntry> active, List<String> 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:

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
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<String, Quest> 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<String, Quest> quests = ctx.getWorld().getQuests();
        List<QuestEntry> 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<String> 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<String, Quest> 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
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:

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):

        if (startQuest != null) {
            return new Effect(Effect.Type.START_QUEST, startQuest);
        }
  • Step 2: Create the quest DTOs

loader/dto/QuestStageDto.java:

package thb.jeanluc.adventure.loader.dto;

import java.util.List;

/** YAML representation of a quest stage. */
public record QuestStageDto(String objective, List<ConditionDto> completion, List<EffectDto> onComplete) {
}

loader/dto/QuestDto.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<QuestStageDto> stages, List<EffectDto> onComplete) {
}
  • Step 3: Create loader/QuestFactory.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:)

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<QuestStage> 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:

import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.model.Quest;

Add an optional-read helper (next to readList):

    private <T> List<T> readListOptional(String resource, Class<T> elementType) {
        try (InputStream in = getClass().getResourceAsStream(resource)) {
            if (in == null) {
                return List.of();
            }
            CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
            List<T> 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:

        List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);

After the existing requireUniqueIds(...) calls, add:

        requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList());

Build the quest map (next to the rooms map build):

        Map<String, Quest> quests = new HashMap<>();
        for (QuestDto dto : questDtos) {
            quests.put(dto.id(), QuestFactory.fromDto(dto));
        }

Change the World construction to pass quests:

        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:

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
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

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:

import thb.jeanluc.adventure.io.text.QuestText;
import thb.jeanluc.adventure.io.text.QuestView;

Add after the map methods:

    /** 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:

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:

    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
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<String> 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:

        registry.register(new QuestsCommand(), "quests", "log", "journal");

Add the import:

import thb.jeanluc.adventure.command.impl.QuestsCommand;
  • Step 6: Write the command test

src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.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
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

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:

import javax.swing.JPanel;
import thb.jeanluc.adventure.io.text.QuestView;

(b) Add fields next to map / sideScroll:

    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:

        map = new MapPanel(baseFont);
        sideScroll = new JScrollPane(map);
        sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));

and replace with:

        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:

        frame.add(sideScroll, BorderLayout.EAST);

and replace with:

        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():

        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):

        quests.setFont(baseFont.deriveFont(small));

(f) Override setQuests (next to setMap):

    @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
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

- 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:

| `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:

> ✅ #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:

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
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.