From 513c7cb8d96dddb9ad1a50b46fbabb51d57f1c60 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:27:18 +0200 Subject: [PATCH] feat(game): QuestEngine (auto-advance + announcements) and QuestView Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanluc/adventure/game/QuestEngine.java | 94 +++++++++++++++++++ .../jeanluc/adventure/io/text/QuestEntry.java | 4 + .../jeanluc/adventure/io/text/QuestView.java | 11 +++ .../thb/jeanluc/adventure/model/World.java | 9 ++ .../adventure/game/QuestEngineTest.java | 63 +++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestEntry.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestView.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestEngineTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java new file mode 100644 index 0000000..7054116 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestEngine.java @@ -0,0 +1,94 @@ +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; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestEntry.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestEntry.java new file mode 100644 index 0000000..8b38613 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestEntry.java @@ -0,0 +1,4 @@ +package thb.jeanluc.adventure.io.text; + +/** An active quest's title and its current objective. */ +public record QuestEntry(String title, String objective) {} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestView.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestView.java new file mode 100644 index 0000000..06cfd35 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestView.java @@ -0,0 +1,11 @@ +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); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java index d2fb395..b478fa8 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java @@ -29,4 +29,13 @@ public class World { /** Welcome message printed once when the game starts. */ private final String welcomeMessage; + + /** Global lookup of quests by id. */ + private final Map quests; + + /** 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()); + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestEngineTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestEngineTest.java new file mode 100644 index 0000000..ef9030e --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestEngineTest.java @@ -0,0 +1,63 @@ +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(); + } +}