1066 lines
35 KiB
Markdown
1066 lines
35 KiB
Markdown
# 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<QuestStage> stages, List<Effect> 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<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`**
|
|
|
|
```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:
|
|
```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<String, Quest> 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<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`:
|
|
```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<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`:
|
|
```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<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**
|
|
|
|
```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<ConditionDto> completion, List<EffectDto> 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<QuestStageDto> stages, List<EffectDto> 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<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:
|
|
```java
|
|
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
|
import thb.jeanluc.adventure.model.Quest;
|
|
```
|
|
Add an optional-read helper (next to `readList`):
|
|
```java
|
|
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:
|
|
```java
|
|
List<QuestDto> 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<String, Quest> 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<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:
|
|
```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.
|