From dd16d1326bc6446ee0ec5cfe0518a9c6803406b9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:25:58 +0200 Subject: [PATCH 1/6] feat(game): Quest model, QuestLog, START_QUEST effect Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/game/Effects.java | 1 + .../jeanluc/adventure/game/GameContext.java | 3 ++ .../thb/jeanluc/adventure/game/QuestLog.java | 49 +++++++++++++++++++ .../thb/jeanluc/adventure/model/Effect.java | 2 +- .../thb/jeanluc/adventure/model/Quest.java | 12 +++++ .../jeanluc/adventure/model/QuestStage.java | 11 +++++ .../jeanluc/adventure/game/EffectsTest.java | 7 +++ .../jeanluc/adventure/game/QuestLogTest.java | 30 ++++++++++++ 8 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Quest.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/QuestStage.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestLogTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java index 317faa3..4f2d738 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java @@ -27,6 +27,7 @@ public final class Effects { } case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg()); case SAY -> ctx.getIo().write(e.arg()); + case START_QUEST -> ctx.getQuestLog().start(e.arg()); } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java index 48617fe..a552b92 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java @@ -26,4 +26,7 @@ public class GameContext { /** Mutable world flags. Created fresh per context; not a constructor arg. */ private final GameState state = new GameState(); + + /** Runtime quest progress. Created fresh per context; not a constructor arg. */ + private final QuestLog questLog = new QuestLog(); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java new file mode 100644 index 0000000..d0d9bb0 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/QuestLog.java @@ -0,0 +1,49 @@ +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); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java index c876efa..ae36755 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java @@ -2,5 +2,5 @@ package thb.jeanluc.adventure.model; /** A single state mutation or message. */ public record Effect(Type type, String arg) { - public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY } + public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY, START_QUEST } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Quest.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Quest.java new file mode 100644 index 0000000..ff0d58d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Quest.java @@ -0,0 +1,12 @@ +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); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/QuestStage.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/QuestStage.java new file mode 100644 index 0000000..87ed93a --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/QuestStage.java @@ -0,0 +1,11 @@ +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); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java index 44d1d2c..8728491 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java @@ -53,4 +53,11 @@ class EffectsTest { Effects.apply(new Effect(Effect.Type.SAY, "boo"), ctx); assertThat(((TestIO) ctx.getIo()).allOutput()).contains("boo"); } + + @Test + void startQuestStartsQuestInLog() { + GameContext ctx = ctx(Map.of()); + Effects.apply(new Effect(Effect.Type.START_QUEST, "restore_power"), ctx); + assertThat(ctx.getQuestLog().isActive("restore_power")).isTrue(); + } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestLogTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestLogTest.java new file mode 100644 index 0000000..1b65415 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/QuestLogTest.java @@ -0,0 +1,30 @@ +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(); + } +} From 513c7cb8d96dddb9ad1a50b46fbabb51d57f1c60 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:27:18 +0200 Subject: [PATCH 2/6] 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(); + } +} From a3600eadd74c30a1db63c6178e96bdd9e04d6563 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:29:52 +0200 Subject: [PATCH 3/6] feat(loader): quests.yaml loading + startQuest effect mapping Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/loader/QuestFactory.java | 36 ++++++++++++++++++ .../jeanluc/adventure/loader/WorldLoader.java | 23 ++++++++++- .../adventure/loader/dto/EffectDto.java | 14 ++++++- .../adventure/loader/dto/QuestDto.java | 8 ++++ .../adventure/loader/dto/QuestStageDto.java | 7 ++++ .../adventure/loader/QuestLoadingTest.java | 38 +++++++++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/QuestFactory.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestStageDto.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/QuestLoadingTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/QuestFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/QuestFactory.java new file mode 100644 index 0000000..9f3e84f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/QuestFactory.java @@ -0,0 +1,36 @@ +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())); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java index b6c8ee3..b783a63 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java @@ -8,9 +8,11 @@ import lombok.extern.slf4j.Slf4j; import thb.jeanluc.adventure.loader.dto.GameDto; import thb.jeanluc.adventure.loader.dto.ItemDto; import thb.jeanluc.adventure.loader.dto.NpcDto; +import thb.jeanluc.adventure.loader.dto.QuestDto; import thb.jeanluc.adventure.loader.dto.RoomDto; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Quest; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.World; import thb.jeanluc.adventure.model.item.Item; @@ -68,11 +70,13 @@ public class WorldLoader { List itemDtos = readList(basePath + "/items.yaml", ItemDto.class); List npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class); List roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class); + List questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class); GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class); requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList()); requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList()); requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList()); + requireUniqueIds("quest", questDtos.stream().map(QuestDto::id).toList()); Map items = new HashMap<>(); for (ItemDto dto : itemDtos) { @@ -86,6 +90,10 @@ public class WorldLoader { for (RoomDto dto : roomDtos) { rooms.put(dto.id(), RoomFactory.shellFromDto(dto)); } + Map quests = new HashMap<>(); + for (QuestDto dto : questDtos) { + quests.put(dto.id(), QuestFactory.fromDto(dto)); + } ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms); resolver.resolveRooms(roomDtos); @@ -98,7 +106,7 @@ public class WorldLoader { Player player = new Player(start, gold); World world = new World(rooms, items, npcs, - gameDto.title(), gameDto.welcomeMessage()); + gameDto.title(), gameDto.welcomeMessage(), quests); log.info("World '{}' loaded: {} rooms, {} items, {} npcs", gameDto.title(), rooms.size(), items.size(), npcs.size()); return new LoadResult(world, player); @@ -114,6 +122,19 @@ public class WorldLoader { } } + 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); + } + } + private T readSingle(String resource, Class type) { try (InputStream in = openResource(resource)) { return yaml.readValue(in, type); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java index ad57aba..25ae0aa 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java @@ -7,7 +7,13 @@ import java.util.ArrayList; import java.util.List; /** YAML effect: exactly one of the fields is set. */ -public record EffectDto(String setFlag, String clearFlag, String giveItem, String removeItem, String say) { +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); + } public Effect toModel() { if (setFlag != null) { @@ -25,7 +31,11 @@ public record EffectDto(String setFlag, String clearFlag, String giveItem, Strin if (say != null) { return new Effect(Effect.Type.SAY, say); } - throw new WorldLoadException("Effect must set one of setFlag/clearFlag/giveItem/removeItem/say"); + if (startQuest != null) { + return new Effect(Effect.Type.START_QUEST, startQuest); + } + throw new WorldLoadException( + "Effect must set one of setFlag/clearFlag/giveItem/removeItem/say/startQuest"); } public static List toModelList(List dtos) { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestDto.java new file mode 100644 index 0000000..0c16d15 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestDto.java @@ -0,0 +1,8 @@ +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) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestStageDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestStageDto.java new file mode 100644 index 0000000..af8e698 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/QuestStageDto.java @@ -0,0 +1,7 @@ +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) { +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/QuestLoadingTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/QuestLoadingTest.java new file mode 100644 index 0000000..c762c92 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/QuestLoadingTest.java @@ -0,0 +1,38 @@ +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); + } +} From 310d4762649dd93c1e575648a640b2d4984147b8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:31:33 +0200 Subject: [PATCH 4/6] feat(io,command): quests command, QuestText, per-turn quest tick Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/thb/jeanluc/adventure/App.java | 2 ++ .../adventure/command/impl/QuestsCommand.java | 21 ++++++++++++ .../java/thb/jeanluc/adventure/game/Game.java | 2 ++ .../java/thb/jeanluc/adventure/io/GameIO.java | 12 +++++++ .../jeanluc/adventure/io/text/QuestText.java | 29 ++++++++++++++++ .../command/impl/QuestsCommandTest.java | 34 +++++++++++++++++++ 6 files changed, 100 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuestsCommand.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestText.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index 817b772..c609650 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -11,6 +11,7 @@ import thb.jeanluc.adventure.command.impl.HelpCommand; import thb.jeanluc.adventure.command.impl.InventoryCommand; import thb.jeanluc.adventure.command.impl.LookCommand; import thb.jeanluc.adventure.command.impl.MapCommand; +import thb.jeanluc.adventure.command.impl.QuestsCommand; import thb.jeanluc.adventure.command.impl.QuitCommand; import thb.jeanluc.adventure.command.impl.ReadCommand; import thb.jeanluc.adventure.command.impl.TakeCommand; @@ -70,6 +71,7 @@ public final class App { registry.register(new ReadCommand(), "read"); registry.register(new ExamineCommand(), "examine", "x", "inspect"); registry.register(new MapCommand(), "map", "m"); + registry.register(new QuestsCommand(), "quests", "log", "journal"); registry.register(new TalkCommand(), "talk", "speak"); registry.register(new GiveCommand(), "give"); registry.register(new HelpCommand(registry), "help", "?"); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuestsCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuestsCommand.java new file mode 100644 index 0000000..ffc2787 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuestsCommand.java @@ -0,0 +1,21 @@ +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)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 24210e3..0d5a5cb 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -60,6 +60,7 @@ public class Game { } private void publishHud() { + QuestEngine.tick(ctx); ctx.getIo().setHud(new Hud( ctx.getPlayer().getCurrentRoom().getName(), ctx.getPlayer().getGold(), @@ -70,6 +71,7 @@ public class Game { ctx.getPlayer().getVisitedRoomIds(), ctx.getPlayer().getCurrentRoom()); ctx.getIo().setMap(map); + ctx.getIo().setQuests(QuestEngine.viewOf(ctx)); } /** diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index 060d88d..98584b0 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -3,6 +3,8 @@ package thb.jeanluc.adventure.io; import thb.jeanluc.adventure.io.text.AsciiMap; import thb.jeanluc.adventure.io.text.Hud; import thb.jeanluc.adventure.io.text.MapView; +import thb.jeanluc.adventure.io.text.QuestText; +import thb.jeanluc.adventure.io.text.QuestView; import thb.jeanluc.adventure.io.text.Renderings; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.io.text.StyledText; @@ -44,4 +46,14 @@ public interface GameIO { default void showMap(MapView view) { print(AsciiMap.render(view, true)); } + + /** 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)); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestText.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestText.java new file mode 100644 index 0000000..da1814e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/QuestText.java @@ -0,0 +1,29 @@ +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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.java new file mode 100644 index 0000000..6b66023 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/QuestsCommandTest.java @@ -0,0 +1,34 @@ +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"); + } +} From e3fb0c9fa697a9ff7e60a524704e2ff4319da098 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:33:57 +0200 Subject: [PATCH 5/6] feat(io): GUI quest-box under the map Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/QuestPanel.java | 60 +++++++++++++++++++ .../thb/jeanluc/adventure/io/SwingIO.java | 31 +++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/QuestPanel.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/QuestPanel.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/QuestPanel.java new file mode 100644 index 0000000..e334dcd --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/QuestPanel.java @@ -0,0 +1,60 @@ +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); + }; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index 86b9b53..fb4e4be 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -2,6 +2,7 @@ package thb.jeanluc.adventure.io; import thb.jeanluc.adventure.io.text.Hud; import thb.jeanluc.adventure.io.text.MapView; +import thb.jeanluc.adventure.io.text.QuestView; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.io.text.Span; import thb.jeanluc.adventure.io.text.Style; @@ -12,6 +13,7 @@ import javax.swing.BorderFactory; import javax.swing.JComponent; import javax.swing.JFrame; import javax.swing.JLabel; +import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTextField; import javax.swing.JTextPane; @@ -70,6 +72,9 @@ public class SwingIO implements GameIO { private final JLabel hud; private final MapPanel map; private final JScrollPane sideScroll; + private final QuestPanel quests; + private final JScrollPane questScroll; + private final JPanel sidePanel; private final Font baseFont; /** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */ @@ -99,6 +104,14 @@ public class SwingIO implements GameIO { 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); + input = new JTextField(); input.addActionListener(e -> { String line = input.getText(); @@ -111,7 +124,7 @@ public class SwingIO implements GameIO { frame.setLayout(new BorderLayout()); frame.add(hud, BorderLayout.NORTH); frame.add(new JScrollPane(output), BorderLayout.CENTER); - frame.add(sideScroll, BorderLayout.EAST); + frame.add(sidePanel, BorderLayout.EAST); frame.add(input, BorderLayout.SOUTH); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setSize((int) (900 * uiScale), (int) (600 * uiScale)); @@ -171,8 +184,9 @@ public class SwingIO implements GameIO { 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))); - sideScroll.setPreferredSize(new Dimension(target, 0)); - sideScroll.revalidate(); + sidePanel.setPreferredSize(new Dimension(target, 0)); + questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32))); + sidePanel.revalidate(); frame.validate(); // The side panel changed size: let the map re-fit into it. map.revalidate(); @@ -197,6 +211,7 @@ public class SwingIO implements GameIO { output.setFont(baseFont.deriveFont(main)); input.setFont(baseFont.deriveFont(main)); hud.setFont(baseFont.deriveFont(small)); + quests.setFont(baseFont.deriveFont(small)); map.setZoom(zoom); frame.revalidate(); frame.repaint(); @@ -312,6 +327,16 @@ public class SwingIO implements GameIO { map.show(view); } + @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. + } + @Override public void showMap(MapView view) { // The GUI map panel is always visible; nothing extra to do on the 'map' command. From 1b22c996e11d4f98f6e9d9dfb74de8e2b4f8484f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:34:57 +0200 Subject: [PATCH 6/6] feat(content): demo quest 'restore_power'; document quests command Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/README.md | 1 + Semesterprojekt/docs/enhancement-ideas.md | 10 +++++++--- Semesterprojekt/src/main/resources/world/quests.yaml | 10 ++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/main/resources/world/quests.yaml diff --git a/Semesterprojekt/README.md b/Semesterprojekt/README.md index 0806541..0b52872 100644 --- a/Semesterprojekt/README.md +++ b/Semesterprojekt/README.md @@ -47,6 +47,7 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui) | `go ` | `move`, `walk` | In eine Himmelsrichtung gehen | | `look` | `l` | Aktuellen Raum beschreiben | | `map` | `m` | Karte der erkundeten Räume anzeigen | +| `quests` | `log`, `journal` | Aktive und erledigte Quests anzeigen | | `inventory` | `inv`, `i` | Inventar anzeigen | | `take ` | `pick`, `get` | Gegenstand aufnehmen | | `drop ` | `put` | Gegenstand ablegen | diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index f48c541..0990c2f 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -81,9 +81,13 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr. > ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags + > Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen, > bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Demo: -> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet. Quest- -> Objekte/Log (#3.2) und Enden (#3.3) sowie Licht/Dunkelheit & Item-Kombination -> stehen noch aus. +> Generator schaltet Strom → Keller-Tür öffnet + Raum wird beleuchtet. +> +> ✅ #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 sowie Licht/Dunkelheit & +> Item-Kombination. - Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item). diff --git a/Semesterprojekt/src/main/resources/world/quests.yaml b/Semesterprojekt/src/main/resources/world/quests.yaml new file mode 100644 index 0000000..0ad1a63 --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/quests.yaml @@ -0,0 +1,10 @@ +- 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 }