Merge quests & quest-log (sub-project 3.2) into develop
Condition-driven multi-stage quests on the foundation: Quest/QuestStage data, QuestLog + QuestEngine (per-turn auto-advance with ✓/★ announcements and cascade), START_QUEST effect, QuestView pushed/rendered like the map. Console 'quests' command and a GUI quest-box under the map. Demo quest restore_power auto-advances as you restore power and earn the key. 116 tests green. Win-condition/endings (3.3) deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
||||
| `go <richtung>` | `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 <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
||||
| `drop <item>` | `put` | Gegenstand ablegen |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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", "?");
|
||||
|
||||
@@ -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<String> args) {
|
||||
ctx.getIo().showQuests(QuestEngine.viewOf(ctx));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String help() {
|
||||
return "quests - show your active and completed quests (alias: log, journal)";
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<QuestEntry> active, List<String> completed) {
|
||||
public QuestView {
|
||||
active = List.copyOf(active);
|
||||
completed = List.copyOf(completed);
|
||||
}
|
||||
}
|
||||
@@ -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<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()));
|
||||
}
|
||||
}
|
||||
@@ -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<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
|
||||
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
|
||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||
List<QuestDto> 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<String, Item> 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<String, Quest> 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 <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);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T readSingle(String resource, Class<T> type) {
|
||||
try (InputStream in = openResource(resource)) {
|
||||
return yaml.readValue(in, type);
|
||||
|
||||
@@ -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<Effect> toModelList(List<EffectDto> dtos) {
|
||||
|
||||
@@ -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<QuestStageDto> stages, List<EffectDto> onComplete) {
|
||||
}
|
||||
@@ -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<ConditionDto> completion, List<EffectDto> onComplete) {
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<QuestStage> stages, List<Effect> onComplete) {
|
||||
public Quest {
|
||||
stages = stages == null ? List.of() : List.copyOf(stages);
|
||||
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
||||
}
|
||||
}
|
||||
@@ -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<Condition> completion, List<Effect> onComplete) {
|
||||
public QuestStage {
|
||||
completion = completion == null ? List.of() : List.copyOf(completion);
|
||||
onComplete = onComplete == null ? List.of() : List.copyOf(onComplete);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Quest> quests;
|
||||
|
||||
/** 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());
|
||||
}
|
||||
}
|
||||
|
||||
10
Semesterprojekt/src/main/resources/world/quests.yaml
Normal file
10
Semesterprojekt/src/main/resources/world/quests.yaml
Normal file
@@ -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 }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user