From f79382c43a4f9daaa998111eaad57edacbb04c7e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:39:59 +0200 Subject: [PATCH 01/10] docs: spec for quest foundation (world-state + conditions/effects) Approach A: one typed condition/effect vocabulary (GameState + Conditions + Effects) reused by five integration points (locked exits, state-dependent descriptions, conditional NPC dialogue, reactions with requires/effects, switch effects). All new YAML fields optional. Light/dark, item-combination, quest objects, endings deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-05-31-quest-foundation-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/specs/2026-05-31-quest-foundation-design.md diff --git a/Semesterprojekt/docs/superpowers/specs/2026-05-31-quest-foundation-design.md b/Semesterprojekt/docs/superpowers/specs/2026-05-31-quest-foundation-design.md new file mode 100644 index 0000000..401d272 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-05-31-quest-foundation-design.md @@ -0,0 +1,185 @@ +# Spec: Quest-Fundament – World-State & Bedingungen/Effekte (Teilprojekt 3.1) + +Stand: 2026-05-31. Erstes Stück des Quest-Systems (Teilprojekt 3) aus +[enhancement-ideas.md](../../enhancement-ideas.md). Liefert die Zustands- und +Bedingungs-/Effekt-Engine, auf der Quests (#3.2) und Enden (#3.3) aufbauen. + +## 1. Kontext & Ziel + +Das NPC-System ist heute statisch: fester `greeting`, Reaktionen +(`consumes`/`gives`/`response`) ohne Bedingungen, kein Weltzustand. Ziel: +**gated progression** datengetrieben ermöglichen – verschlossene Türen, +zustandsabhängige Beschreibungen, reaktive NPCs, Schalter, die Weltzustand setzen – +über **eine** kleine, wiederverwendbare Bedingungs-/Effekt-Sprache (Ansatz A). + +Scope **core-only** (bestätigt): Licht/Dunkelheit und Item-Kombination werden +bewusst auf spätere kleine Folge-Specs verschoben. + +## 2. Scope + +**In Scope:** +- **`GameState`** – Flag-Speicher (benannte Booleans). +- **Condition/Effect-Modell** + Auswerter (`Conditions`) + Anwender (`Effects`). +- Fünf Integrationspunkte: verschlossene Exits, zustandsabhängige + Raumbeschreibungen, bedingte NPC-Dialoge, Reaktionen mit `requires`/`effects`, + Schalter mit `effects`. +- Loader/DTO/Factory/Resolver-Erweiterungen, alle neuen YAML-Felder **optional** + (Rückwärtskompatibilität). + +**Out of Scope (eigene spätere Specs):** +- Licht & Dunkelheit; Item-Kombination (`use X on Y`). +- Quest-Objekte, Quest-Log, GUI-Quest-Box (Teilprojekt #3.2). +- Win-Condition / mehrere Enden (#3.3). +- `startFlags`-Seeding (YAGNI – Flags starten leer = alle false, was der + Ausgangslage „Strom aus, Keller geflutet" entspricht). + +## 3. Kern-Engine (Ansatz A) + +### 3.1 GameState + +`GameState` (Package `game`) hält die gesetzten Flags als `Set` +(vorhanden = true). API: `isSet(name)`, `set(name)`, `clear(name)`, `all()`. + +`GameContext` bekommt ein **selbst-initialisiertes** Feld +`private final GameState state = new GameState();` (per `@Getter` lesbar). Da das +Feld initialisiert ist, bleibt der `@RequiredArgsConstructor` (world, player, io) +**unverändert** – keine Anpassung der bestehenden Konstruktionsstellen nötig. + +### 3.2 Condition (Daten in `model`, Auswerter in `game`) + +```java +public record Condition(Type type, String arg) { + public enum Type { FLAG, NOT_FLAG, HAS_ITEM } +} +``` +`Conditions.all(List, GameContext)` → true, wenn alle gelten (leere +Liste = true): +- `FLAG` → `state.isSet(arg)` +- `NOT_FLAG` → `!state.isSet(arg)` +- `HAS_ITEM` → `player.hasItem(arg)` + +### 3.3 Effect (Daten in `model`, Anwender in `game`) + +```java +public record Effect(Type type, String arg) { + public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY } +} +``` +`Effects.applyAll(List, GameContext)` wendet jeden an: +- `SET_FLAG`/`CLEAR_FLAG` → `state.set/clear(arg)` +- `GIVE_ITEM` → Item aus `world.getItems().get(arg)` ins Inventar (unbekannt → + Warnung geloggt, übersprungen) +- `REMOVE_ITEM` → `player.removeItem(arg)` +- `SAY` → `io.write(arg)` + +## 4. Integrationspunkte + +Jeder trägt nur `requires` (Conditions) und/oder `effects` und ruft die Helfer. + +| # | Träger (model) | YAML-Feld | Konsument | Verhalten | +|---|---|---|---|---| +| 1 | `Room.exitLocks: EnumMap` | `exitLocks` | `GoCommand` | Vor Bewegung: gilt `requires` nicht → `blocked` ausgeben, nicht bewegen | +| 2 | `Room.descriptionStates: List` | `descriptionStates` | `LookCommand` | Erste passende Variante als Beschreibung, sonst `description` | +| 3 | `Npc.dialogue: List` | `dialogue` | `TalkCommand` | Erste passende Zeile, sonst `greeting` | +| 4 | `NpcReaction.requires/effects` | `requires`, `effects` | `GiveCommand` | `requires` prüfen; bei Erfolg consume/give wie bisher + `effects` | +| 5 | `SwitchableItem.effects: List` | `effects` | `use` | Beim Schalten **auf „on"**: `effects` anwenden | + +Hilfstypen (records in `model`): `ExitLock(List requires, String blocked)`, +`DescriptionState(List when, String text)`, +`DialogueLine(List when, String text)`. + +### YAML-Beispiele + +```yaml +# rooms.yaml +exits: { east: cellar } +exitLocks: + - direction: east + requires: [{ flag: power_on }] + blocked: "The cellar door won't budge without power." +descriptionStates: + - when: [{ flag: power_on }] + text: "The cellar, now lit, reveals a workbench." + +# npcs.yaml +dialogue: + - when: [{ flag: power_on }] + text: '"You did it — the lights are back!"' +reactions: + - onReceive: fuse + requires: [{ flag: cellar_drained }] + response: "That'll hold now." + effects: + - { removeItem: fuse } + - { setFlag: fuse_installed } + +# items.yaml +- id: breaker + type: switchable + name: Breaker + onText: "You flip the breaker. The manor hums to life." + offText: "You switch the breaker off." + state: false + effects: + - { setFlag: power_on } +``` + +## 5. Loading / DTO / Mapping + +- Neue DTOs (`loader.dto`): `ConditionDto(flag, notFlag, hasItem)`, + `EffectDto(setFlag, clearFlag, giveItem, removeItem, say)`, `ExitLockDto`, + `DescriptionStateDto`, `DialogueLineDto`. Jeweils `toModel()`. +- Erweiterte DTOs: `RoomDto` (+`exitLocks`, +`descriptionStates`), `NpcDto` + (+`dialogue`), `ReactionDto` (+`requires`, +`effects`), `ItemDto` (+`effects`). +- `ConditionDto.toModel()`: genau ein Feld gesetzt → entsprechende `Condition`, + sonst `WorldLoadException`. Analog `EffectDto`. +- Verdrahtung: `ReferenceResolver.resolveRooms` baut `exitLocks` (Direction via + `Direction.fromString`) und `descriptionStates`; `resolveNpcs` baut `dialogue` + und erweitert die `NpcReaction` um `requires`/`effects`; `ItemFactory` setzt + `SwitchableItem.effects`. +- **Item-Referenzen in Conditions/Effects** (`hasItem`/`giveItem`/`removeItem`) + werden **nicht** beim Laden aufgelöst, sondern zur Laufzeit (lazy) – kein + Eingriff in die strikte Resolver-Validierung; `GIVE_ITEM` unbekannt ist + laufzeit-tolerant (Warnung). + +## 6. Fehlerbehandlung + +| Fall | Verhalten | +|---|---| +| Condition/Effect-DTO ohne gesetztes Feld | `WorldLoadException` beim Laden | +| `exitLocks` mit unbekannter Richtung | `WorldLoadException` (`Direction.fromString`) | +| `GIVE_ITEM` mit unbekannter Item-ID | Warnung geloggt, Effekt übersprungen | +| Neue Felder fehlen (alte YAMLs) | null → als leere Liste behandelt, lädt normal | +| Schalter `off`→`on` mehrfach | `effects` werden bei jedem Übergang auf „on" angewendet (idempotent bei `setFlag`) | + +## 7. Rückwärtskompatibilität + +Alle neuen Felder sind optional (nullable). Bestehende `rooms.yaml`, `npcs.yaml`, +`items.yaml` und alle Test-Fixtures laden unverändert; die 90 bestehenden Tests +bleiben grün. `NpcReaction` behält `consumes`/`gives`/`response`. + +## 8. Testing + +- **Unit:** `GameState` (set/clear/isSet); `Conditions.all` (FLAG/NOT_FLAG/HAS_ITEM, + AND, leer=true); `Effects.applyAll` (alle fünf Typen, unbekanntes Item tolerant); + `ConditionDto.toModel`/`EffectDto.toModel` (inkl. Fehlerfall). +- **Integration (in-Code-Welt wie bestehende Command-Tests):** + - `GoCommand`: gesperrter Exit blockt ohne Flag, passiert mit Flag. + - `LookCommand`: Beschreibung wechselt mit Flag. + - `TalkCommand`: Dialogzeile wechselt mit Flag, sonst `greeting`. + - `GiveCommand`: Reaktion mit `requires` blockt/erlaubt; `effects` setzen Flag. + - `SwitchableItem.use`: setzt Flag beim Einschalten. +- **Loader:** ein Round-Trip-Test, der ein YAML mit allen neuen Feldern lädt; + ein Test, dass alte YAMLs ohne neue Felder weiterhin laden. + +## 9. Demonstration (am Ende, minimal) + +Eine kleine, sichere Erweiterung der echten Welt-YAMLs, um die Kette +end-to-end spielbar zu zeigen (z. B. Schalter setzt ein Flag, das eine Tür +öffnet und eine Beschreibung ändert) – ohne bestehende Tests zu berühren. + +## 10. Offene Detailfragen (in Implementierung entscheidbar) + +- `SAY`-Effekt: schlicht `io.write` oder gestylt (`StyledText`) – zunächst `write`. +- Ob `ExitLock`/`DescriptionState`/`DialogueLine` als records in `model` oder + `model.state` liegen – im Plan festgelegt (`model`). From c4542165abed93b60da9d13468cef7c4112c5c41 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:44:37 +0200 Subject: [PATCH 02/10] docs: implementation plan for quest foundation Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-31-quest-foundation.md | 1383 +++++++++++++++++ 1 file changed, 1383 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/plans/2026-05-31-quest-foundation.md diff --git a/Semesterprojekt/docs/superpowers/plans/2026-05-31-quest-foundation.md b/Semesterprojekt/docs/superpowers/plans/2026-05-31-quest-foundation.md new file mode 100644 index 0000000..7d31ebb --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-05-31-quest-foundation.md @@ -0,0 +1,1383 @@ +# Quest Foundation (World-State + Conditions/Effects) 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:** Add a world-flag store and one typed condition/effect vocabulary, then wire it into five integration points (locked exits, state-dependent descriptions, conditional NPC dialogue, reactions with requires/effects, switch effects) so the world supports data-driven gated progression. + +**Architecture:** `GameState` (flags) lives in `GameContext`. `Condition`/`Effect` are pure data records in `model`; `Conditions.all(...)` and `Effects.applyAll(...)` in `game` evaluate/apply them against the context. Each integration point carries `requires`/`effects` lists and calls these two helpers. All new YAML fields are optional → existing worlds and the 90 tests are unaffected. + +**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok, Jackson/SnakeYAML. + +Spec: [docs/superpowers/specs/2026-05-31-quest-foundation-design.md](../specs/2026-05-31-quest-foundation-design.md) + +--- + +## Task 1: Core engine — `GameState`, `Condition`, `Effect`, `Conditions`, `Effects` + +**Files:** +- Create: `game/GameState.java`, `model/Condition.java`, `model/Effect.java`, `game/Conditions.java`, `game/Effects.java` +- Modify: `game/GameContext.java` +- Test: `game/GameStateTest.java`, `game/ConditionsTest.java`, `game/EffectsTest.java` + +- [ ] **Step 1: Write failing tests** + +`src/test/java/thb/jeanluc/adventure/game/GameStateTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class GameStateTest { + @Test + void setIsSetClear() { + GameState s = new GameState(); + assertThat(s.isSet("power_on")).isFalse(); + s.set("power_on"); + assertThat(s.isSet("power_on")).isTrue(); + s.clear("power_on"); + assertThat(s.isSet("power_on")).isFalse(); + } +} +``` + +`src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConditionsTest { + private GameContext ctx() { + Player p = new Player(new Room("k", "K", "d"), 0); + return new GameContext(null, p, new TestIO()); + } + + @Test + void flagAndNotFlag() { + GameContext ctx = ctx(); + ctx.getState().set("power_on"); + assertThat(Conditions.holds(new Condition(Condition.Type.FLAG, "power_on"), ctx)).isTrue(); + assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "power_on"), ctx)).isFalse(); + assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "x"), ctx)).isTrue(); + } + + @Test + void hasItem() { + GameContext ctx = ctx(); + ctx.getPlayer().addItem(PlainItem.builder().id("lamp").name("Lamp").description("d").build()); + assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "lamp"), ctx)).isTrue(); + assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "key"), ctx)).isFalse(); + } + + @Test + void allRequiresEveryConditionAndEmptyIsTrue() { + GameContext ctx = ctx(); + ctx.getState().set("a"); + assertThat(Conditions.all(List.of(), ctx)).isTrue(); + assertThat(Conditions.all(List.of(new Condition(Condition.Type.FLAG, "a")), ctx)).isTrue(); + assertThat(Conditions.all(List.of( + new Condition(Condition.Type.FLAG, "a"), + new Condition(Condition.Type.FLAG, "b")), ctx)).isFalse(); + } +} +``` + +`src/test/java/thb/jeanluc/adventure/game/EffectsTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EffectsTest { + private GameContext ctx(Map items) { + Player p = new Player(new Room("k", "K", "d"), 0); + World w = new World(Map.of(), items, Map.of(), "t", "w"); + return new GameContext(w, p, new TestIO()); + } + + @Test + void setAndClearFlag() { + GameContext ctx = ctx(Map.of()); + Effects.applyAll(List.of(new Effect(Effect.Type.SET_FLAG, "power_on")), ctx); + assertThat(ctx.getState().isSet("power_on")).isTrue(); + Effects.apply(new Effect(Effect.Type.CLEAR_FLAG, "power_on"), ctx); + assertThat(ctx.getState().isSet("power_on")).isFalse(); + } + + @Test + void giveAndRemoveItem() { + Item key = PlainItem.builder().id("key").name("Key").description("d").build(); + GameContext ctx = ctx(Map.of("key", key)); + Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "key"), ctx); + assertThat(ctx.getPlayer().hasItem("key")).isTrue(); + Effects.apply(new Effect(Effect.Type.REMOVE_ITEM, "key"), ctx); + assertThat(ctx.getPlayer().hasItem("key")).isFalse(); + } + + @Test + void giveUnknownItemIsTolerated() { + GameContext ctx = ctx(Map.of()); + Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "ghost"), ctx); + assertThat(ctx.getPlayer().hasItem("ghost")).isFalse(); + } + + @Test + void sayWritesToIo() { + GameContext ctx = ctx(Map.of()); + Effects.apply(new Effect(Effect.Type.SAY, "boo"), ctx); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("boo"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=GameStateTest,ConditionsTest,EffectsTest` +Expected: FAIL — types don't exist. + +- [ ] **Step 3: Create the engine** + +`game/GameState.java`: +```java +package thb.jeanluc.adventure.game; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** Named world flags. A flag is "set" (true) when present in the set. */ +public class GameState { + + private final Set flags = new LinkedHashSet<>(); + + public boolean isSet(String flag) { + return flags.contains(flag); + } + + public void set(String flag) { + flags.add(flag); + } + + public void clear(String flag) { + flags.remove(flag); + } + + public Set all() { + return Collections.unmodifiableSet(flags); + } +} +``` + +`model/Condition.java`: +```java +package thb.jeanluc.adventure.model; + +/** A single boolean test evaluated against world state and the player. */ +public record Condition(Type type, String arg) { + public enum Type { FLAG, NOT_FLAG, HAS_ITEM } +} +``` + +`model/Effect.java`: +```java +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 } +} +``` + +`game/Conditions.java`: +```java +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.model.Condition; + +import java.util.List; + +/** Evaluates {@link Condition}s against a {@link GameContext}. */ +public final class Conditions { + + private Conditions() { + } + + public static boolean holds(Condition c, GameContext ctx) { + return switch (c.type()) { + case FLAG -> ctx.getState().isSet(c.arg()); + case NOT_FLAG -> !ctx.getState().isSet(c.arg()); + case HAS_ITEM -> ctx.getPlayer().hasItem(c.arg()); + }; + } + + /** True iff every condition holds. A null or empty list is vacuously true. */ + public static boolean all(List conditions, GameContext ctx) { + if (conditions == null) { + return true; + } + for (Condition c : conditions) { + if (!holds(c, ctx)) { + return false; + } + } + return true; + } +} +``` + +`game/Effects.java`: +```java +package thb.jeanluc.adventure.game; + +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; + +/** Applies {@link Effect}s to a {@link GameContext}. */ +@Slf4j +public final class Effects { + + private Effects() { + } + + public static void apply(Effect e, GameContext ctx) { + switch (e.type()) { + case SET_FLAG -> ctx.getState().set(e.arg()); + case CLEAR_FLAG -> ctx.getState().clear(e.arg()); + case GIVE_ITEM -> { + Item it = ctx.getWorld().getItems().get(e.arg()); + if (it == null) { + log.warn("Effect GIVE_ITEM references unknown item '{}'", e.arg()); + } else { + ctx.getPlayer().addItem(it); + } + } + case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg()); + case SAY -> ctx.getIo().write(e.arg()); + } + } + + /** Applies each effect in order. A null list is a no-op. */ + public static void applyAll(List effects, GameContext ctx) { + if (effects == null) { + return; + } + for (Effect e : effects) { + apply(e, ctx); + } + } +} +``` + +- [ ] **Step 4: Add `GameState` to `GameContext`** + +In `game/GameContext.java`, add after the `io` field (the initializer keeps it out of the `@RequiredArgsConstructor`): +```java + /** Mutable world flags. Created fresh per context; not a constructor arg. */ + private final GameState state = new GameState(); +``` + +- [ ] **Step 5: Run to verify pass** + +Run: `mvn -q test -Dtest=GameStateTest,ConditionsTest,EffectsTest` +Expected: PASS. +Run: `mvn -q test` +Expected: PASS (full suite, GameContext change is additive). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/GameState.java \ + src/main/java/thb/jeanluc/adventure/game/Conditions.java \ + src/main/java/thb/jeanluc/adventure/game/Effects.java \ + src/main/java/thb/jeanluc/adventure/game/GameContext.java \ + src/main/java/thb/jeanluc/adventure/model/Condition.java \ + src/main/java/thb/jeanluc/adventure/model/Effect.java \ + src/test/java/thb/jeanluc/adventure/game/GameStateTest.java \ + src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java \ + src/test/java/thb/jeanluc/adventure/game/EffectsTest.java +git commit -m "feat(game): world-state engine (GameState, Condition/Effect, evaluator/applier)" +``` + +--- + +## Task 2: Condition/Effect DTOs + +**Files:** +- Create: `loader/dto/ConditionDto.java`, `loader/dto/EffectDto.java` +- Test: `loader/dto/ConditionEffectDtoTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/loader/dto/ConditionEffectDtoTest.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.WorldLoadException; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Effect; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ConditionEffectDtoTest { + @Test + void conditionToModel() { + assertThat(new ConditionDto("power_on", null, null).toModel()) + .isEqualTo(new Condition(Condition.Type.FLAG, "power_on")); + assertThat(new ConditionDto(null, "x", null).toModel().type()) + .isEqualTo(Condition.Type.NOT_FLAG); + assertThat(new ConditionDto(null, null, "lamp").toModel().type()) + .isEqualTo(Condition.Type.HAS_ITEM); + } + + @Test + void emptyConditionThrows() { + assertThatThrownBy(() -> new ConditionDto(null, null, null).toModel()) + .isInstanceOf(WorldLoadException.class); + } + + @Test + void effectToModel() { + assertThat(new EffectDto("p", null, null, null, null).toModel()) + .isEqualTo(new Effect(Effect.Type.SET_FLAG, "p")); + assertThat(new EffectDto(null, null, "key", null, null).toModel().type()) + .isEqualTo(Effect.Type.GIVE_ITEM); + assertThat(new EffectDto(null, null, null, null, "boo").toModel().type()) + .isEqualTo(Effect.Type.SAY); + } + + @Test + void emptyEffectThrows() { + assertThatThrownBy(() -> new EffectDto(null, null, null, null, null).toModel()) + .isInstanceOf(WorldLoadException.class); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=ConditionEffectDtoTest` +Expected: FAIL — DTOs don't exist. + +- [ ] **Step 3: Create the DTOs** + +`loader/dto/ConditionDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import thb.jeanluc.adventure.loader.WorldLoadException; +import thb.jeanluc.adventure.model.Condition; + +import java.util.ArrayList; +import java.util.List; + +/** YAML condition: exactly one of the fields is set. */ +public record ConditionDto(String flag, String notFlag, String hasItem) { + + public Condition toModel() { + if (flag != null) { + return new Condition(Condition.Type.FLAG, flag); + } + if (notFlag != null) { + return new Condition(Condition.Type.NOT_FLAG, notFlag); + } + if (hasItem != null) { + return new Condition(Condition.Type.HAS_ITEM, hasItem); + } + throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem"); + } + + public static List toModelList(List dtos) { + List out = new ArrayList<>(); + if (dtos != null) { + for (ConditionDto d : dtos) { + out.add(d.toModel()); + } + } + return out; + } +} +``` + +`loader/dto/EffectDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import thb.jeanluc.adventure.loader.WorldLoadException; +import thb.jeanluc.adventure.model.Effect; + +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 Effect toModel() { + if (setFlag != null) { + return new Effect(Effect.Type.SET_FLAG, setFlag); + } + if (clearFlag != null) { + return new Effect(Effect.Type.CLEAR_FLAG, clearFlag); + } + if (giveItem != null) { + return new Effect(Effect.Type.GIVE_ITEM, giveItem); + } + if (removeItem != null) { + return new Effect(Effect.Type.REMOVE_ITEM, removeItem); + } + if (say != null) { + return new Effect(Effect.Type.SAY, say); + } + throw new WorldLoadException("Effect must set one of setFlag/clearFlag/giveItem/removeItem/say"); + } + + public static List toModelList(List dtos) { + List out = new ArrayList<>(); + if (dtos != null) { + for (EffectDto d : dtos) { + out.add(d.toModel()); + } + } + return out; + } +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `mvn -q test -Dtest=ConditionEffectDtoTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/loader/dto/ConditionDto.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java \ + src/test/java/thb/jeanluc/adventure/loader/dto/ConditionEffectDtoTest.java +git commit -m "feat(loader): ConditionDto/EffectDto with toModel mapping" +``` + +--- + +## Task 3: Locked exits + +**Files:** +- Create: `model/ExitLock.java`, `loader/dto/ExitLockDto.java` +- Modify: `model/Room.java`, `loader/dto/RoomDto.java`, `loader/ReferenceResolver.java`, `command/impl/GoCommand.java` +- Test: `command/impl/LockedExitTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java`: +```java +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.ExitLock; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LockedExitTest { + @Test + void lockedExitBlocksUntilFlagSet() { + Room kitchen = new Room("kitchen", "Kitchen", "d"); + Room cellar = new Room("cellar", "Cellar", "d"); + kitchen.addExit(Direction.EAST, cellar); + kitchen.addExitLock(Direction.EAST, new ExitLock( + List.of(new Condition(Condition.Type.FLAG, "power_on")), + "The cellar door is sealed.")); + Player player = new Player(kitchen, 0); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new GoCommand().execute(ctx, List.of("east")); + assertThat(player.getCurrentRoom()).isEqualTo(kitchen); + assertThat(io.allOutput()).contains("sealed"); + + ctx.getState().set("power_on"); + new GoCommand().execute(ctx, List.of("east")); + assertThat(player.getCurrentRoom()).isEqualTo(cellar); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=LockedExitTest` +Expected: FAIL — `ExitLock` / `addExitLock` missing. + +- [ ] **Step 3: Create `model/ExitLock.java`** + +```java +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** A condition-gated exit with a message shown when it is blocked. */ +public record ExitLock(List requires, String blocked) { + public ExitLock { + requires = requires == null ? List.of() : List.copyOf(requires); + } +} +``` + +- [ ] **Step 4: Add exit locks to `Room.java`** + +Add the import: +```java +import java.util.List; +``` +Add the field next to `exits`: +```java + /** Optional condition-gates per exit direction. */ + private final EnumMap exitLocks = new EnumMap<>(Direction.class); +``` +Add the mutator (near `addExit`): +```java + /** + * Adds a condition-gate to the exit in the given direction. + * + * @param direction the gated direction + * @param lock the lock to apply + */ + public void addExitLock(Direction direction, ExitLock lock) { + exitLocks.put(direction, lock); + } +``` + +- [ ] **Step 5: Add `exitLocks` to `RoomDto.java` and create `ExitLockDto`** + +`loader/dto/ExitLockDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of a single condition-gated exit. */ +public record ExitLockDto(String direction, List requires, String blocked) { +} +``` + +In `RoomDto.java`, add the field as the last record component: +```java + List npcs, + List exitLocks +``` +(append `, List exitLocks` after `npcs`, and add the matching Javadoc `@param exitLocks` line). + +- [ ] **Step 6: Wire exit locks in `ReferenceResolver.resolveRooms`** + +Add imports: +```java +import thb.jeanluc.adventure.loader.dto.ConditionDto; +import thb.jeanluc.adventure.loader.dto.ExitLockDto; +import thb.jeanluc.adventure.model.ExitLock; +``` +At the end of the per-room loop body in `resolveRooms` (after the `npcs` block), add: +```java + if (dto.exitLocks() != null) { + for (ExitLockDto lock : dto.exitLocks()) { + Direction direction; + try { + direction = Direction.fromString(lock.direction()); + } catch (IllegalArgumentException ex) { + throw new WorldLoadException( + "Room '" + dto.id() + "' has exit lock with unknown direction '" + lock.direction() + "'"); + } + room.addExitLock(direction, new ExitLock( + ConditionDto.toModelList(lock.requires()), lock.blocked())); + } + } +``` + +- [ ] **Step 7: Enforce the lock in `GoCommand`** + +Add imports: +```java +import thb.jeanluc.adventure.game.Conditions; +import thb.jeanluc.adventure.model.ExitLock; +``` +After the `next.isEmpty()` check and before `ctx.getPlayer().setCurrentRoom(next.get())`, insert: +```java + ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir); + if (lock != null && !Conditions.all(lock.requires(), ctx)) { + ctx.getIo().write(lock.blocked()); + return; + } +``` + +- [ ] **Step 8: Run tests** + +Run: `mvn -q test -Dtest=LockedExitTest` +Expected: PASS. +Run: `mvn -q test` +Expected: PASS (full suite). + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/ExitLock.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java \ + src/main/java/thb/jeanluc/adventure/model/Room.java \ + src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java \ + src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \ + src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java +git commit -m "feat: condition-locked exits" +``` + +--- + +## Task 4: State-dependent room descriptions + +**Files:** +- Create: `model/DescriptionState.java`, `loader/dto/DescriptionStateDto.java` +- Modify: `model/Room.java`, `loader/dto/RoomDto.java`, `loader/ReferenceResolver.java`, `command/impl/LookCommand.java` +- Test: `command/impl/DescriptionStateTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/command/impl/DescriptionStateTest.java`: +```java +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.DescriptionState; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DescriptionStateTest { + @Test + void descriptionSwitchesWithFlag() { + Room cellar = new Room("cellar", "Cellar", "Pitch black down here."); + cellar.addDescriptionState(new DescriptionState( + List.of(new Condition(Condition.Type.FLAG, "power_on")), + "Lit now, a workbench stands against the wall.")); + Player player = new Player(cellar, 0); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new LookCommand().execute(ctx, List.of()); + assertThat(io.allOutput()).contains("Pitch black"); + + io.outputs().clear(); + ctx.getState().set("power_on"); + new LookCommand().execute(ctx, List.of()); + assertThat(io.allOutput()).contains("workbench").doesNotContain("Pitch black"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=DescriptionStateTest` +Expected: FAIL — `DescriptionState` / `addDescriptionState` missing. + +- [ ] **Step 3: Create `model/DescriptionState.java`** + +```java +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** An alternate room description shown while its conditions hold. */ +public record DescriptionState(List when, String text) { + public DescriptionState { + when = when == null ? List.of() : List.copyOf(when); + } +} +``` + +- [ ] **Step 4: Add description states to `Room.java`** + +Add imports: +```java +import java.util.ArrayList; +``` +Add the field: +```java + /** Optional condition-gated description variants, first match wins. */ + private final List descriptionStates = new ArrayList<>(); +``` +Add the mutator: +```java + /** + * Adds a condition-gated description variant. + * + * @param state the description variant + */ + public void addDescriptionState(DescriptionState state) { + descriptionStates.add(state); + } +``` + +- [ ] **Step 5: Add `descriptionStates` to `RoomDto` and create `DescriptionStateDto`** + +`loader/dto/DescriptionStateDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of a conditional room description. */ +public record DescriptionStateDto(List when, String text) { +} +``` + +In `RoomDto.java`, append a new component after `exitLocks`: +```java + List exitLocks, + List descriptionStates +``` +(and the matching `@param descriptionStates` Javadoc line). + +- [ ] **Step 6: Wire in `ReferenceResolver.resolveRooms`** + +Add imports: +```java +import thb.jeanluc.adventure.loader.dto.DescriptionStateDto; +import thb.jeanluc.adventure.model.DescriptionState; +``` +After the `exitLocks` block in the room loop, add: +```java + if (dto.descriptionStates() != null) { + for (DescriptionStateDto ds : dto.descriptionStates()) { + room.addDescriptionState(new DescriptionState( + ConditionDto.toModelList(ds.when()), ds.text())); + } + } +``` + +- [ ] **Step 7: Resolve the active description in `LookCommand`** + +Add imports: +```java +import thb.jeanluc.adventure.game.Conditions; +import thb.jeanluc.adventure.model.DescriptionState; +``` +Replace the `RoomView` construction so the description is resolved first. Change: +```java + ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits)); +``` +to: +```java + String description = room.getDescription(); + for (DescriptionState ds : room.getDescriptionStates()) { + if (Conditions.all(ds.when(), ctx)) { + description = ds.text(); + break; + } + } + ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits)); +``` + +- [ ] **Step 8: Run tests** + +Run: `mvn -q test -Dtest=DescriptionStateTest` +Expected: PASS. +Run: `mvn -q test` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/DescriptionState.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java \ + src/main/java/thb/jeanluc/adventure/model/Room.java \ + src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java \ + src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java \ + src/test/java/thb/jeanluc/adventure/command/impl/DescriptionStateTest.java +git commit -m "feat: state-dependent room descriptions" +``` + +--- + +## Task 5: Conditional NPC dialogue + +**Files:** +- Create: `model/DialogueLine.java`, `loader/dto/DialogueLineDto.java` +- Modify: `model/Npc.java`, `loader/dto/NpcDto.java`, `loader/ReferenceResolver.java`, `command/impl/TalkCommand.java` +- Test: `command/impl/DialogueTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/command/impl/DialogueTest.java`: +```java +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.DialogueLine; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DialogueTest { + @Test + void dialogueSwitchesWithFlagElseGreeting() { + Room room = new Room("k", "K", "d"); + Npc man = Npc.shell("man", "Old Man", "stooped", "Hello there."); + man.addDialogue(new DialogueLine( + List.of(new Condition(Condition.Type.FLAG, "power_on")), + "The lights are back, bless you!")); + room.addNpc(man); + Player player = new Player(room, 0); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new TalkCommand().execute(ctx, List.of("man")); + assertThat(io.allOutput()).contains("Hello there"); + + io.outputs().clear(); + ctx.getState().set("power_on"); + new TalkCommand().execute(ctx, List.of("man")); + assertThat(io.allOutput()).contains("lights are back").doesNotContain("Hello there"); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=DialogueTest` +Expected: FAIL — `DialogueLine` / `addDialogue` missing. + +- [ ] **Step 3: Create `model/DialogueLine.java`** + +```java +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** A conditional line an NPC says on {@code talk}, first match wins. */ +public record DialogueLine(List when, String text) { + public DialogueLine { + when = when == null ? List.of() : List.copyOf(when); + } +} +``` + +- [ ] **Step 4: Add dialogue to `Npc.java`** + +Add imports: +```java +import lombok.Builder; +import java.util.ArrayList; +import java.util.List; +``` +Add the field (with `@Builder.Default` so every builder path initialises it): +```java + /** Optional condition-gated dialogue lines, first match wins; else {@link #greeting}. */ + @Builder.Default + private final List dialogue = new ArrayList<>(); +``` +Add the mutator: +```java + /** + * Adds a conditional dialogue line. + * + * @param line the dialogue line + */ + public void addDialogue(DialogueLine line) { + dialogue.add(line); + } +``` + +- [ ] **Step 5: Add `dialogue` to `NpcDto` and create `DialogueLineDto`** + +`loader/dto/DialogueLineDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of a conditional NPC line. */ +public record DialogueLineDto(List when, String text) { +} +``` + +In `NpcDto.java`, append a component after `reactions`: +```java + List reactions, + List dialogue +``` +(and the `@param dialogue` Javadoc line). + +- [ ] **Step 6: Wire dialogue in `ReferenceResolver.resolveNpcs`** + +Add imports: +```java +import thb.jeanluc.adventure.loader.dto.ConditionDto; +import thb.jeanluc.adventure.loader.dto.DialogueLineDto; +import thb.jeanluc.adventure.model.DialogueLine; +``` +Replace the early `if (dto.reactions() == null) { continue; }` so dialogue is still processed. Change the start of the per-DTO loop body from: +```java + if (dto.reactions() == null) { + continue; + } + Npc npc = npcs.get(dto.id()); + for (ReactionDto r : dto.reactions()) { +``` +to: +```java + Npc npc = npcs.get(dto.id()); + if (dto.dialogue() != null) { + for (DialogueLineDto dl : dto.dialogue()) { + npc.addDialogue(new DialogueLine(ConditionDto.toModelList(dl.when()), dl.text())); + } + } + if (dto.reactions() == null) { + continue; + } + for (ReactionDto r : dto.reactions()) { +``` + +- [ ] **Step 7: Resolve the active line in `TalkCommand`** + +Add imports: +```java +import thb.jeanluc.adventure.game.Conditions; +import thb.jeanluc.adventure.model.DialogueLine; +``` +Replace: +```java + ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting()); +``` +with: +```java + Npc found = npc.get(); + String line = found.getGreeting(); + for (DialogueLine dl : found.getDialogue()) { + if (Conditions.all(dl.when(), ctx)) { + line = dl.text(); + break; + } + } + ctx.getIo().write(found.getName() + " says: " + line); +``` + +- [ ] **Step 8: Run tests** + +Run: `mvn -q test -Dtest=DialogueTest` +Expected: PASS. +Run: `mvn -q test` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/DialogueLine.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java \ + src/main/java/thb/jeanluc/adventure/model/Npc.java \ + src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java \ + src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java \ + src/test/java/thb/jeanluc/adventure/command/impl/DialogueTest.java +git commit -m "feat: conditional NPC dialogue" +``` + +--- + +## Task 6: Reactions with requires + effects + +**Files:** +- Modify: `model/NpcReaction.java`, `loader/dto/ReactionDto.java`, `loader/ReferenceResolver.java`, `command/impl/GiveCommand.java` +- Test: `command/impl/ReactionEffectsTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/command/impl/ReactionEffectsTest.java`: +```java +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.NpcReaction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReactionEffectsTest { + @Test + void reactionRequiresFlagAndAppliesEffects() { + Room room = new Room("k", "K", "d"); + Npc man = Npc.shell("man", "Old Man", "stooped", "hi"); + man.putReaction("fuse", NpcReaction.builder() + .requires(List.of(new Condition(Condition.Type.FLAG, "cellar_drained"))) + .effects(List.of(new Effect(Effect.Type.SET_FLAG, "fuse_installed"))) + .response("Done.") + .build()); + room.addNpc(man); + Player player = new Player(room, 0); + player.addItem(PlainItem.builder().id("fuse").name("Fuse").description("d").build()); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new GiveCommand().execute(ctx, List.of("fuse", "man")); + assertThat(ctx.getState().isSet("fuse_installed")).isFalse(); // requires not met + + ctx.getState().set("cellar_drained"); + new GiveCommand().execute(ctx, List.of("fuse", "man")); + assertThat(ctx.getState().isSet("fuse_installed")).isTrue(); + assertThat(io.allOutput()).contains("Done."); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=ReactionEffectsTest` +Expected: FAIL — `requires`/`effects` not on `NpcReaction`. + +- [ ] **Step 3: Extend `NpcReaction.java`** + +Add imports: +```java +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Effect; +import java.util.List; +``` +(They are already in package `model`, so drop the package-qualified imports — just `import java.util.List;`.) Add two fields next to the existing ones: +```java + /** Conditions that must hold for the exchange; null/empty = always. */ + private final List requires; + + /** Effects applied after a successful exchange; null = none. */ + private final List effects; +``` + +- [ ] **Step 4: Add `requires`/`effects` to `ReactionDto.java`** + +Append two components: +```java + String consumes, + List requires, + List effects +``` +Add imports `java.util.List` and the `@param` lines. + +- [ ] **Step 5: Wire in `ReferenceResolver.resolveNpcs`** + +Add imports: +```java +import thb.jeanluc.adventure.loader.dto.EffectDto; +``` +Extend the `NpcReaction.builder()` call to set the new fields: +```java + NpcReaction reaction = NpcReaction.builder() + .consumes(consumes) + .gives(gives) + .response(r.response()) + .requires(ConditionDto.toModelList(r.requires())) + .effects(EffectDto.toModelList(r.effects())) + .build(); +``` + +- [ ] **Step 6: Enforce requires + apply effects in `GiveCommand`** + +Add imports: +```java +import thb.jeanluc.adventure.game.Conditions; +import thb.jeanluc.adventure.game.Effects; +``` +Replace the block from `NpcReaction reaction = reactionOpt.get();` to the final `write` with: +```java + NpcReaction reaction = reactionOpt.get(); + if (!Conditions.all(reaction.getRequires(), ctx)) { + ctx.getIo().write(npc.getName() + " isn't ready for that yet."); + return; + } + if (reaction.getConsumes() != null) { + ctx.getPlayer().removeItem(reaction.getConsumes().getId()); + } + if (reaction.getGives() != null) { + ctx.getPlayer().addItem(reaction.getGives()); + } + Effects.applyAll(reaction.getEffects(), ctx); + ctx.getIo().write(npc.getName() + ": " + reaction.getResponse()); +``` + +- [ ] **Step 7: Run tests** + +Run: `mvn -q test -Dtest=ReactionEffectsTest` +Expected: PASS. +Run: `mvn -q test` +Expected: PASS (existing `TalkGiveCommandTest` still green — builders without requires/effects pass null, guarded). + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/NpcReaction.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java \ + src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java \ + src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java \ + src/test/java/thb/jeanluc/adventure/command/impl/ReactionEffectsTest.java +git commit -m "feat: NPC reactions gated by conditions, applying effects" +``` + +--- + +## Task 7: Switch effects + +**Files:** +- Modify: `model/item/SwitchableItem.java`, `loader/dto/ItemDto.java`, `loader/ItemFactory.java` +- Test: `model/item/SwitchEffectsTest.java` + +- [ ] **Step 1: Write failing test** + +`src/test/java/thb/jeanluc/adventure/model/item/SwitchEffectsTest.java`: +```java +package thb.jeanluc.adventure.model.item; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SwitchEffectsTest { + @Test + void switchingOnAppliesEffects() { + SwitchableItem breaker = SwitchableItem.builder() + .id("breaker").name("Breaker").description("d") + .state(false).onText("hums to life").offText("off") + .effects(List.of(new Effect(Effect.Type.SET_FLAG, "power_on"))) + .build(); + Player player = new Player(new Room("k", "K", "d"), 0); + GameContext ctx = new GameContext(null, player, new TestIO()); + + breaker.use(ctx); + assertThat(ctx.getState().isSet("power_on")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `mvn -q test -Dtest=SwitchEffectsTest` +Expected: FAIL — `effects` not on `SwitchableItem`. + +- [ ] **Step 3: Add effects to `SwitchableItem.java`** + +Add imports: +```java +import thb.jeanluc.adventure.game.Effects; +import thb.jeanluc.adventure.model.Effect; +import java.util.List; +``` +Add the field: +```java + /** Effects applied when this item transitions to on; null = none. */ + private final List effects; +``` +Replace `use` with: +```java + @Override + public void use(GameContext ctx) { + state = !state; + ctx.getIo().write(state ? onText : offText); + if (state) { + Effects.applyAll(effects, ctx); + } + } +``` + +- [ ] **Step 4: Add `effects` to `ItemDto.java`** + +Append a component: +```java + String offText, + List effects +``` +Add `import java.util.List;` and the `@param effects` line. + +- [ ] **Step 5: Wire in `ItemFactory.java`** + +In the `"switchable"` case of the switch, add `.effects(...)` before `.build()`: +```java + case "switchable" -> SwitchableItem.builder() + .id(dto.id()) + .name(dto.name()) + .description(dto.description()) + .state(Boolean.TRUE.equals(dto.initialState())) + .onText(dto.onText()) + .offText(dto.offText()) + .effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects())) + .build(); +``` + +- [ ] **Step 6: Run tests** + +Run: `mvn -q test -Dtest=SwitchEffectsTest` +Expected: PASS. +Run: `mvn -q test` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java \ + src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java \ + src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java \ + src/test/java/thb/jeanluc/adventure/model/item/SwitchEffectsTest.java +git commit -m "feat: switchable items apply effects when turned on" +``` + +--- + +## Task 8: End-to-end demo content + docs + final verification + +**Files:** +- Modify: `src/main/resources/world/items.yaml`, `rooms.yaml`, `npcs.yaml` +- Modify: `docs/enhancement-ideas.md` + +- [ ] **Step 1: Add a generator switch to `items.yaml`** + +Append: +```yaml +- id: generator + type: switchable + name: Generator + description: A rusty generator with a heavy lever. + state: false + onText: | + You heave the lever. Somewhere deep in the manor, the lights flicker on. + offText: | + You pull the lever back; the manor falls dark again. + effects: + - { setFlag: power_on } +``` + +- [ ] **Step 2: Gate the cellar and light it in `rooms.yaml`** + +In the `kitchen` room, add an exit lock under its existing fields: +```yaml + exitLocks: + - direction: east + requires: [{ flag: power_on }] + blocked: | + The cellar door is held shut by a magnetic lock — dead without power. +``` +Put the generator in the `dungeon` room (add to its `items` list): change `items: []` to `items: [generator]`. +Give the `cellar` a lit variant — add under the cellar room: +```yaml + descriptionStates: + - when: [{ flag: power_on }] + text: | + With the power back, a single bulb reveals shelves of dusty jars. +``` + +- [ ] **Step 3: Add a reactive line to `npcs.yaml`** + +Under `old_man`, add: +```yaml + dialogue: + - when: [{ flag: power_on }] + text: | + "You got the power running! The whole house feels less... watchful now." +``` + +- [ ] **Step 4: Run the full suite** + +Run: `mvn -q test` +Expected: PASS (90 + new tests; fixtures unchanged). + +- [ ] **Step 5: End-to-end console smoke** + +Run: +```bash +printf 'go east\ngo south\nuse generator\ngo north\ngo east\nlook\ntalk old_man\nquit\n' | mvn -q -DskipTests exec:java@run +``` +Expected: `go east` from the kitchen is blocked ("magnetic lock"); after `go south` to the dungeon and `use generator` (power_on), returning north and `go east` succeeds; `look` in the cellar shows the lit "dusty jars" description; the old man's reactive line is unlocked. No exceptions. + +(Note: the kitchen→cellar exit is `east`; the first `go east` should be blocked, proving the lock.) + +- [ ] **Step 6: Mark the foundation done in the backlog** + +In `docs/enhancement-ideas.md`, under `### 5. Quest-System (NPC-Ausbau)`, add a status line: +```markdown +> ✅ Fundament umgesetzt (Branch `feature/quest-foundation`): World-Flags + +> Condition/Effect-Engine, verschlossene Exits, zustandsabhängige Beschreibungen, +> bedingte Dialoge, Reaktionen mit requires/effects, Schalter-Effekte. Quest- +> Objekte/Log (#3.2) und Enden (#3.3) offen. +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/main/resources/world/items.yaml \ + src/main/resources/world/rooms.yaml \ + src/main/resources/world/npcs.yaml \ + docs/enhancement-ideas.md +git commit -m "feat(content): demo the state engine (generator unlocks/lights the cellar)" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** engine (T1), DTOs (T2), locked exits (T3), state descriptions (T4), conditional dialogue (T5), reaction requires/effects (T6), switch effects (T7), demo+docs (T8). Light/dark, item-combination, quest objects, endings explicitly deferred. +- **Backward compatibility:** every new DTO field is appended and optional; `NpcReaction` keeps `consumes`/`gives`/`response`; null `requires`/`effects`/`dialogue` are guarded (`Conditions.all(null)=true`, `Effects.applyAll(null)=no-op`, `@Builder.Default` empty dialogue). Existing fixtures and the 90 tests are untouched. +- **Type consistency:** `Conditions.all(List, ctx)`, `Effects.applyAll(List, ctx)`, `ConditionDto.toModelList`/`EffectDto.toModelList`, `Room.addExitLock`/`addDescriptionState`, `Npc.addDialogue`, `NpcReaction.getRequires`/`getEffects`, `SwitchableItem` builder `.effects(...)` used consistently. +- **Layering:** `model` records (`Condition`, `Effect`, `ExitLock`, `DescriptionState`, `DialogueLine`) carry no `game` dependency; evaluator/applier (`game`) depend on `model`. No cycles. From bf10f11438b59b1b130fd1220bf3c9143a330d89 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:46:29 +0200 Subject: [PATCH 03/10] feat(game): world-state engine (GameState, Condition/Effect, evaluator/applier) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanluc/adventure/game/Conditions.java | 33 +++++++++++ .../thb/jeanluc/adventure/game/Effects.java | 42 ++++++++++++++ .../jeanluc/adventure/game/GameContext.java | 3 + .../thb/jeanluc/adventure/game/GameState.java | 27 +++++++++ .../jeanluc/adventure/model/Condition.java | 6 ++ .../thb/jeanluc/adventure/model/Effect.java | 6 ++ .../adventure/game/ConditionsTest.java | 47 ++++++++++++++++ .../jeanluc/adventure/game/EffectsTest.java | 56 +++++++++++++++++++ .../jeanluc/adventure/game/GameStateTest.java | 16 ++++++ 9 files changed, 236 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java new file mode 100644 index 0000000..1e96113 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Conditions.java @@ -0,0 +1,33 @@ +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.model.Condition; + +import java.util.List; + +/** Evaluates {@link Condition}s against a {@link GameContext}. */ +public final class Conditions { + + private Conditions() { + } + + public static boolean holds(Condition c, GameContext ctx) { + return switch (c.type()) { + case FLAG -> ctx.getState().isSet(c.arg()); + case NOT_FLAG -> !ctx.getState().isSet(c.arg()); + case HAS_ITEM -> ctx.getPlayer().hasItem(c.arg()); + }; + } + + /** True iff every condition holds. A null or empty list is vacuously true. */ + public static boolean all(List conditions, GameContext ctx) { + if (conditions == null) { + return true; + } + for (Condition c : conditions) { + if (!holds(c, ctx)) { + return false; + } + } + return true; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java new file mode 100644 index 0000000..317faa3 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Effects.java @@ -0,0 +1,42 @@ +package thb.jeanluc.adventure.game; + +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; + +/** Applies {@link Effect}s to a {@link GameContext}. */ +@Slf4j +public final class Effects { + + private Effects() { + } + + public static void apply(Effect e, GameContext ctx) { + switch (e.type()) { + case SET_FLAG -> ctx.getState().set(e.arg()); + case CLEAR_FLAG -> ctx.getState().clear(e.arg()); + case GIVE_ITEM -> { + Item it = ctx.getWorld().getItems().get(e.arg()); + if (it == null) { + log.warn("Effect GIVE_ITEM references unknown item '{}'", e.arg()); + } else { + ctx.getPlayer().addItem(it); + } + } + case REMOVE_ITEM -> ctx.getPlayer().removeItem(e.arg()); + case SAY -> ctx.getIo().write(e.arg()); + } + } + + /** Applies each effect in order. A null list is a no-op. */ + public static void applyAll(List effects, GameContext ctx) { + if (effects == null) { + return; + } + for (Effect e : effects) { + apply(e, ctx); + } + } +} 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 7f3f41c..48617fe 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java @@ -23,4 +23,7 @@ public class GameContext { /** IO channel for reading input and writing output. */ private final GameIO io; + + /** Mutable world flags. Created fresh per context; not a constructor arg. */ + private final GameState state = new GameState(); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java new file mode 100644 index 0000000..135f03f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameState.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.game; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** Named world flags. A flag is "set" (true) when present in the set. */ +public class GameState { + + private final Set flags = new LinkedHashSet<>(); + + public boolean isSet(String flag) { + return flags.contains(flag); + } + + public void set(String flag) { + flags.add(flag); + } + + public void clear(String flag) { + flags.remove(flag); + } + + public Set all() { + return Collections.unmodifiableSet(flags); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java new file mode 100644 index 0000000..8381f34 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Condition.java @@ -0,0 +1,6 @@ +package thb.jeanluc.adventure.model; + +/** A single boolean test evaluated against world state and the player. */ +public record Condition(Type type, String arg) { + public enum Type { FLAG, NOT_FLAG, HAS_ITEM } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java new file mode 100644 index 0000000..c876efa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Effect.java @@ -0,0 +1,6 @@ +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 } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java new file mode 100644 index 0000000..1476f64 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/ConditionsTest.java @@ -0,0 +1,47 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConditionsTest { + private GameContext ctx() { + Player p = new Player(new Room("k", "K", "d"), 0); + return new GameContext(null, p, new TestIO()); + } + + @Test + void flagAndNotFlag() { + GameContext ctx = ctx(); + ctx.getState().set("power_on"); + assertThat(Conditions.holds(new Condition(Condition.Type.FLAG, "power_on"), ctx)).isTrue(); + assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "power_on"), ctx)).isFalse(); + assertThat(Conditions.holds(new Condition(Condition.Type.NOT_FLAG, "x"), ctx)).isTrue(); + } + + @Test + void hasItem() { + GameContext ctx = ctx(); + ctx.getPlayer().addItem(PlainItem.builder().id("lamp").name("Lamp").description("d").build()); + assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "lamp"), ctx)).isTrue(); + assertThat(Conditions.holds(new Condition(Condition.Type.HAS_ITEM, "key"), ctx)).isFalse(); + } + + @Test + void allRequiresEveryConditionAndEmptyIsTrue() { + GameContext ctx = ctx(); + ctx.getState().set("a"); + assertThat(Conditions.all(List.of(), ctx)).isTrue(); + assertThat(Conditions.all(List.of(new Condition(Condition.Type.FLAG, "a")), ctx)).isTrue(); + assertThat(Conditions.all(List.of( + new Condition(Condition.Type.FLAG, "a"), + new Condition(Condition.Type.FLAG, "b")), ctx)).isFalse(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java new file mode 100644 index 0000000..44d1d2c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/EffectsTest.java @@ -0,0 +1,56 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class EffectsTest { + private GameContext ctx(Map items) { + Player p = new Player(new Room("k", "K", "d"), 0); + World w = new World(Map.of(), items, Map.of(), "t", "w"); + return new GameContext(w, p, new TestIO()); + } + + @Test + void setAndClearFlag() { + GameContext ctx = ctx(Map.of()); + Effects.applyAll(List.of(new Effect(Effect.Type.SET_FLAG, "power_on")), ctx); + assertThat(ctx.getState().isSet("power_on")).isTrue(); + Effects.apply(new Effect(Effect.Type.CLEAR_FLAG, "power_on"), ctx); + assertThat(ctx.getState().isSet("power_on")).isFalse(); + } + + @Test + void giveAndRemoveItem() { + Item key = PlainItem.builder().id("key").name("Key").description("d").build(); + GameContext ctx = ctx(Map.of("key", key)); + Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "key"), ctx); + assertThat(ctx.getPlayer().hasItem("key")).isTrue(); + Effects.apply(new Effect(Effect.Type.REMOVE_ITEM, "key"), ctx); + assertThat(ctx.getPlayer().hasItem("key")).isFalse(); + } + + @Test + void giveUnknownItemIsTolerated() { + GameContext ctx = ctx(Map.of()); + Effects.apply(new Effect(Effect.Type.GIVE_ITEM, "ghost"), ctx); + assertThat(ctx.getPlayer().hasItem("ghost")).isFalse(); + } + + @Test + void sayWritesToIo() { + GameContext ctx = ctx(Map.of()); + Effects.apply(new Effect(Effect.Type.SAY, "boo"), ctx); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("boo"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java new file mode 100644 index 0000000..d4f91c2 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameStateTest.java @@ -0,0 +1,16 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class GameStateTest { + @Test + void setIsSetClear() { + GameState s = new GameState(); + assertThat(s.isSet("power_on")).isFalse(); + s.set("power_on"); + assertThat(s.isSet("power_on")).isTrue(); + s.clear("power_on"); + assertThat(s.isSet("power_on")).isFalse(); + } +} From c339d1324465cf37a1a32ae3dd1053047dbcfc89 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:47:11 +0200 Subject: [PATCH 04/10] feat(loader): ConditionDto/EffectDto with toModel mapping Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/loader/dto/ConditionDto.java | 34 +++++++++++++++ .../adventure/loader/dto/EffectDto.java | 40 +++++++++++++++++ .../loader/dto/ConditionEffectDtoTest.java | 43 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ConditionDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/dto/ConditionEffectDtoTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ConditionDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ConditionDto.java new file mode 100644 index 0000000..b68550f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ConditionDto.java @@ -0,0 +1,34 @@ +package thb.jeanluc.adventure.loader.dto; + +import thb.jeanluc.adventure.loader.WorldLoadException; +import thb.jeanluc.adventure.model.Condition; + +import java.util.ArrayList; +import java.util.List; + +/** YAML condition: exactly one of the fields is set. */ +public record ConditionDto(String flag, String notFlag, String hasItem) { + + public Condition toModel() { + if (flag != null) { + return new Condition(Condition.Type.FLAG, flag); + } + if (notFlag != null) { + return new Condition(Condition.Type.NOT_FLAG, notFlag); + } + if (hasItem != null) { + return new Condition(Condition.Type.HAS_ITEM, hasItem); + } + throw new WorldLoadException("Condition must set one of flag/notFlag/hasItem"); + } + + public static List toModelList(List dtos) { + List out = new ArrayList<>(); + if (dtos != null) { + for (ConditionDto d : dtos) { + out.add(d.toModel()); + } + } + return out; + } +} 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 new file mode 100644 index 0000000..ad57aba --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/EffectDto.java @@ -0,0 +1,40 @@ +package thb.jeanluc.adventure.loader.dto; + +import thb.jeanluc.adventure.loader.WorldLoadException; +import thb.jeanluc.adventure.model.Effect; + +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 Effect toModel() { + if (setFlag != null) { + return new Effect(Effect.Type.SET_FLAG, setFlag); + } + if (clearFlag != null) { + return new Effect(Effect.Type.CLEAR_FLAG, clearFlag); + } + if (giveItem != null) { + return new Effect(Effect.Type.GIVE_ITEM, giveItem); + } + if (removeItem != null) { + return new Effect(Effect.Type.REMOVE_ITEM, removeItem); + } + if (say != null) { + return new Effect(Effect.Type.SAY, say); + } + throw new WorldLoadException("Effect must set one of setFlag/clearFlag/giveItem/removeItem/say"); + } + + public static List toModelList(List dtos) { + List out = new ArrayList<>(); + if (dtos != null) { + for (EffectDto d : dtos) { + out.add(d.toModel()); + } + } + return out; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/dto/ConditionEffectDtoTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/dto/ConditionEffectDtoTest.java new file mode 100644 index 0000000..2a81cae --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/dto/ConditionEffectDtoTest.java @@ -0,0 +1,43 @@ +package thb.jeanluc.adventure.loader.dto; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.WorldLoadException; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Effect; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ConditionEffectDtoTest { + @Test + void conditionToModel() { + assertThat(new ConditionDto("power_on", null, null).toModel()) + .isEqualTo(new Condition(Condition.Type.FLAG, "power_on")); + assertThat(new ConditionDto(null, "x", null).toModel().type()) + .isEqualTo(Condition.Type.NOT_FLAG); + assertThat(new ConditionDto(null, null, "lamp").toModel().type()) + .isEqualTo(Condition.Type.HAS_ITEM); + } + + @Test + void emptyConditionThrows() { + assertThatThrownBy(() -> new ConditionDto(null, null, null).toModel()) + .isInstanceOf(WorldLoadException.class); + } + + @Test + void effectToModel() { + assertThat(new EffectDto("p", null, null, null, null).toModel()) + .isEqualTo(new Effect(Effect.Type.SET_FLAG, "p")); + assertThat(new EffectDto(null, null, "key", null, null).toModel().type()) + .isEqualTo(Effect.Type.GIVE_ITEM); + assertThat(new EffectDto(null, null, null, null, "boo").toModel().type()) + .isEqualTo(Effect.Type.SAY); + } + + @Test + void emptyEffectThrows() { + assertThatThrownBy(() -> new EffectDto(null, null, null, null, null).toModel()) + .isInstanceOf(WorldLoadException.class); + } +} From e3b80736dd903da6187c31e2639e2aef5d8054ed Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:52:06 +0200 Subject: [PATCH 05/10] feat: condition-locked exits (+ room/dialogue state plumbing) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/command/impl/GoCommand.java | 7 ++++ .../adventure/loader/ReferenceResolver.java | 27 ++++++++++++++ .../loader/dto/DescriptionStateDto.java | 7 ++++ .../adventure/loader/dto/DialogueLineDto.java | 7 ++++ .../adventure/loader/dto/ExitLockDto.java | 7 ++++ .../jeanluc/adventure/loader/dto/RoomDto.java | 11 +++++- .../adventure/model/DescriptionState.java | 10 +++++ .../jeanluc/adventure/model/DialogueLine.java | 10 +++++ .../thb/jeanluc/adventure/model/ExitLock.java | 10 +++++ .../thb/jeanluc/adventure/model/Room.java | 27 ++++++++++++++ .../command/impl/LockedExitTest.java | 37 +++++++++++++++++++ 11 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java index e790bd5..ac31efb 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java @@ -1,8 +1,10 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Room; import java.util.List; @@ -32,6 +34,11 @@ public class GoCommand implements Command { ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); return; } + ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir); + if (lock != null && !Conditions.all(lock.requires(), ctx)) { + ctx.getIo().write(lock.blocked()); + return; + } ctx.getPlayer().setCurrentRoom(next.get()); new LookCommand().execute(ctx, List.of()); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java index de1849d..fc10a46 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java @@ -1,10 +1,18 @@ package thb.jeanluc.adventure.loader; import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.loader.dto.ConditionDto; +import thb.jeanluc.adventure.loader.dto.DescriptionStateDto; +import thb.jeanluc.adventure.loader.dto.DialogueLineDto; +import thb.jeanluc.adventure.loader.dto.EffectDto; +import thb.jeanluc.adventure.loader.dto.ExitLockDto; import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.ReactionDto; import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.DescriptionState; +import thb.jeanluc.adventure.model.DialogueLine; import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.ExitLock; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.NpcReaction; import thb.jeanluc.adventure.model.Room; @@ -75,6 +83,25 @@ public class ReferenceResolver { room.addNpc(npc); } } + if (dto.exitLocks() != null) { + for (ExitLockDto lock : dto.exitLocks()) { + Direction direction; + try { + direction = Direction.fromString(lock.direction()); + } catch (IllegalArgumentException ex) { + throw new WorldLoadException( + "Room '" + dto.id() + "' has exit lock with unknown direction '" + lock.direction() + "'"); + } + room.addExitLock(direction, new ExitLock( + ConditionDto.toModelList(lock.requires()), lock.blocked())); + } + } + if (dto.descriptionStates() != null) { + for (DescriptionStateDto ds : dto.descriptionStates()) { + room.addDescriptionState(new DescriptionState( + ConditionDto.toModelList(ds.when()), ds.text())); + } + } } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java new file mode 100644 index 0000000..e08d9cb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DescriptionStateDto.java @@ -0,0 +1,7 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of a conditional room description. */ +public record DescriptionStateDto(List when, String text) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java new file mode 100644 index 0000000..2fe3464 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/DialogueLineDto.java @@ -0,0 +1,7 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of a conditional NPC line. */ +public record DialogueLineDto(List when, String text) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java new file mode 100644 index 0000000..9bce7fa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ExitLockDto.java @@ -0,0 +1,7 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of a single condition-gated exit. */ +public record ExitLockDto(String direction, List requires, String blocked) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java index 44237d5..9a42aca 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java @@ -12,6 +12,8 @@ import java.util.Map; * @param exits direction-name to target-room-id map * @param items ids of items initially in this room * @param npcs ids of NPCs initially in this room + * @param exitLocks optional condition-gates per exit direction + * @param descriptionStates optional condition-gated description variants */ public record RoomDto( String id, @@ -19,6 +21,13 @@ public record RoomDto( String description, Map exits, List items, - List npcs + List npcs, + List exitLocks, + List descriptionStates ) { + /** Backward-compatible constructor without the optional state fields. */ + public RoomDto(String id, String name, String description, + Map exits, List items, List npcs) { + this(id, name, description, exits, items, npcs, null, null); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java new file mode 100644 index 0000000..4b2362e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DescriptionState.java @@ -0,0 +1,10 @@ +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** An alternate room description shown while its conditions hold. */ +public record DescriptionState(List when, String text) { + public DescriptionState { + when = when == null ? List.of() : List.copyOf(when); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java new file mode 100644 index 0000000..4b02a11 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/DialogueLine.java @@ -0,0 +1,10 @@ +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** A conditional line an NPC says on {@code talk}, first match wins. */ +public record DialogueLine(List when, String text) { + public DialogueLine { + when = when == null ? List.of() : List.copyOf(when); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java new file mode 100644 index 0000000..9a28d7d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/ExitLock.java @@ -0,0 +1,10 @@ +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** A condition-gated exit with a message shown when it is blocked. */ +public record ExitLock(List requires, String blocked) { + public ExitLock { + requires = requires == null ? List.of() : List.copyOf(requires); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java index f104fed..eca90b2 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java @@ -4,8 +4,10 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import thb.jeanluc.adventure.model.item.Item; +import java.util.ArrayList; import java.util.EnumMap; import java.util.LinkedHashMap; +import java.util.List; import java.util.Optional; /** @@ -42,6 +44,12 @@ public class Room { /** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */ private final LinkedHashMap npcs = new LinkedHashMap<>(); + /** Optional condition-gates per exit direction. */ + private final EnumMap exitLocks = new EnumMap<>(Direction.class); + + /** Optional condition-gated description variants, first match wins. */ + private final List descriptionStates = new ArrayList<>(); + /** * Connects this room to another in the given direction. Does not * create the reverse connection — callers must set that up @@ -54,6 +62,25 @@ public class Room { exits.put(direction, neighbour); } + /** + * Adds a condition-gate to the exit in the given direction. + * + * @param direction the gated direction + * @param lock the lock to apply + */ + public void addExitLock(Direction direction, ExitLock lock) { + exitLocks.put(direction, lock); + } + + /** + * Adds a condition-gated description variant (first match wins). + * + * @param state the description variant + */ + public void addDescriptionState(DescriptionState state) { + descriptionStates.add(state); + } + /** * Looks up the room reachable in the given direction. * diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java new file mode 100644 index 0000000..0147b09 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LockedExitTest.java @@ -0,0 +1,37 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.ExitLock; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LockedExitTest { + @Test + void lockedExitBlocksUntilFlagSet() { + Room kitchen = new Room("kitchen", "Kitchen", "d"); + Room cellar = new Room("cellar", "Cellar", "d"); + kitchen.addExit(Direction.EAST, cellar); + kitchen.addExitLock(Direction.EAST, new ExitLock( + List.of(new Condition(Condition.Type.FLAG, "power_on")), + "The cellar door is sealed.")); + Player player = new Player(kitchen, 0); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new GoCommand().execute(ctx, List.of("east")); + assertThat(player.getCurrentRoom()).isEqualTo(kitchen); + assertThat(io.allOutput()).contains("sealed"); + + ctx.getState().set("power_on"); + new GoCommand().execute(ctx, List.of("east")); + assertThat(player.getCurrentRoom()).isEqualTo(cellar); + } +} From 8cb2b360e62e06d8519e79c2cc3c3e6fbc4d5483 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:52:57 +0200 Subject: [PATCH 06/10] feat: state-dependent room descriptions (LookCommand resolution) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/command/impl/LookCommand.java | 11 +++++- .../command/impl/DescriptionStateTest.java | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DescriptionStateTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java index 2a1fb5b..d5a08c8 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java @@ -1,8 +1,10 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.io.text.RoomView; +import thb.jeanluc.adventure.model.DescriptionState; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Room; @@ -22,7 +24,14 @@ public class LookCommand implements Command { List items = room.getItems().values().stream().map(Item::getName).toList(); List npcs = room.getNpcs().values().stream().map(Npc::getName).toList(); List exits = room.getExits().keySet().stream().map(Direction::getLabel).toList(); - ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits)); + String description = room.getDescription(); + for (DescriptionState ds : room.getDescriptionStates()) { + if (Conditions.all(ds.when(), ctx)) { + description = ds.text(); + break; + } + } + ctx.getIo().showRoom(new RoomView(room.getName(), description, items, npcs, exits)); } @Override diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DescriptionStateTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DescriptionStateTest.java new file mode 100644 index 0000000..d1c0a17 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DescriptionStateTest.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.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.DescriptionState; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DescriptionStateTest { + @Test + void descriptionSwitchesWithFlag() { + Room cellar = new Room("cellar", "Cellar", "Pitch black down here."); + cellar.addDescriptionState(new DescriptionState( + List.of(new Condition(Condition.Type.FLAG, "power_on")), + "Lit now, a workbench stands against the wall.")); + Player player = new Player(cellar, 0); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new LookCommand().execute(ctx, List.of()); + assertThat(io.allOutput()).contains("Pitch black"); + + io.outputs().clear(); + ctx.getState().set("power_on"); + new LookCommand().execute(ctx, List.of()); + assertThat(io.allOutput()).contains("workbench").doesNotContain("Pitch black"); + } +} From 9d23cb01b7807979b05e1f362b7d8986b15271f5 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:55:15 +0200 Subject: [PATCH 07/10] feat: conditional NPC dialogue Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/command/impl/TalkCommand.java | 12 +++++- .../adventure/loader/ReferenceResolver.java | 7 +++- .../jeanluc/adventure/loader/dto/NpcDto.java | 8 +++- .../java/thb/jeanluc/adventure/model/Npc.java | 15 ++++++++ .../adventure/command/impl/DialogueTest.java | 37 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DialogueTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java index 30409b6..5c249b5 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java @@ -1,7 +1,9 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Conditions; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.DialogueLine; import thb.jeanluc.adventure.model.Npc; import java.util.List; @@ -24,7 +26,15 @@ public class TalkCommand implements Command { ctx.getIo().write("There is no '" + npcId + "' here to talk to."); return; } - ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting()); + Npc found = npc.get(); + String line = found.getGreeting(); + for (DialogueLine dl : found.getDialogue()) { + if (Conditions.all(dl.when(), ctx)) { + line = dl.text(); + break; + } + } + ctx.getIo().write(found.getName() + " says: " + line); } @Override diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java index fc10a46..17eda2a 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java @@ -113,10 +113,15 @@ public class ReferenceResolver { */ public void resolveNpcs(List npcDtos) { for (NpcDto dto : npcDtos) { + Npc npc = npcs.get(dto.id()); + if (dto.dialogue() != null) { + for (DialogueLineDto dl : dto.dialogue()) { + npc.addDialogue(new DialogueLine(ConditionDto.toModelList(dl.when()), dl.text())); + } + } if (dto.reactions() == null) { continue; } - Npc npc = npcs.get(dto.id()); for (ReactionDto r : dto.reactions()) { if (r.onReceive() == null) { throw new WorldLoadException( diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java index 23afcb1..2b37c2c 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java @@ -10,12 +10,18 @@ import java.util.List; * @param description examine description * @param greeting text the NPC says on {@code talk} * @param reactions list of trigger/response definitions; may be null + * @param dialogue optional condition-gated dialogue lines; may be null */ public record NpcDto( String id, String name, String description, String greeting, - List reactions + List reactions, + List dialogue ) { + /** Backward-compatible constructor without dialogue. */ + public NpcDto(String id, String name, String description, String greeting, List reactions) { + this(id, name, description, greeting, reactions, null); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java index f6e9435..8e07457 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java @@ -3,7 +3,9 @@ package thb.jeanluc.adventure.model; import lombok.Builder; import lombok.Getter; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,6 +36,19 @@ public class Npc { */ private final Map reactions; + /** Optional condition-gated dialogue lines, first match wins; else {@link #greeting}. */ + @Builder.Default + private final List dialogue = new ArrayList<>(); + + /** + * Adds a conditional dialogue line. + * + * @param line the dialogue line + */ + public void addDialogue(DialogueLine line) { + dialogue.add(line); + } + /** * Looks up a reaction triggered by an item. * diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DialogueTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DialogueTest.java new file mode 100644 index 0000000..13e4ffe --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/DialogueTest.java @@ -0,0 +1,37 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.DialogueLine; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class DialogueTest { + @Test + void dialogueSwitchesWithFlagElseGreeting() { + Room room = new Room("k", "K", "d"); + Npc man = Npc.shell("man", "Old Man", "stooped", "Hello there."); + man.addDialogue(new DialogueLine( + List.of(new Condition(Condition.Type.FLAG, "power_on")), + "The lights are back, bless you!")); + room.addNpc(man); + Player player = new Player(room, 0); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new TalkCommand().execute(ctx, List.of("man")); + assertThat(io.allOutput()).contains("Hello there"); + + io.outputs().clear(); + ctx.getState().set("power_on"); + new TalkCommand().execute(ctx, List.of("man")); + assertThat(io.allOutput()).contains("lights are back").doesNotContain("Hello there"); + } +} From ab008ee5628165ae2cc99234a4c4eacc0909485d Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:57:23 +0200 Subject: [PATCH 08/10] feat: NPC reactions gated by conditions, applying effects Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/command/impl/GiveCommand.java | 7 ++++ .../adventure/loader/ReferenceResolver.java | 2 + .../adventure/loader/dto/ReactionDto.java | 12 +++++- .../jeanluc/adventure/model/NpcReaction.java | 8 ++++ .../command/impl/ReactionEffectsTest.java | 42 +++++++++++++++++++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/ReactionEffectsTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java index 8c3ac00..a48ca1b 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java @@ -1,6 +1,8 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Conditions; +import thb.jeanluc.adventure.game.Effects; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.NpcReaction; @@ -44,12 +46,17 @@ public class GiveCommand implements Command { } NpcReaction reaction = reactionOpt.get(); + if (!Conditions.all(reaction.getRequires(), ctx)) { + ctx.getIo().write(npc.getName() + " isn't ready for that yet."); + return; + } if (reaction.getConsumes() != null) { ctx.getPlayer().removeItem(reaction.getConsumes().getId()); } if (reaction.getGives() != null) { ctx.getPlayer().addItem(reaction.getGives()); } + Effects.applyAll(reaction.getEffects(), ctx); ctx.getIo().write(npc.getName() + ": " + reaction.getResponse()); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java index 17eda2a..541877c 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java @@ -155,6 +155,8 @@ public class ReferenceResolver { .consumes(consumes) .gives(gives) .response(r.response()) + .requires(ConditionDto.toModelList(r.requires())) + .effects(EffectDto.toModelList(r.effects())) .build(); npc.putReaction(r.onReceive(), reaction); } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java index be1266a..2fb817f 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java @@ -1,5 +1,7 @@ package thb.jeanluc.adventure.loader.dto; +import java.util.List; + /** * YAML representation of a single NPC reaction. * @@ -7,11 +9,19 @@ package thb.jeanluc.adventure.loader.dto; * @param response text the NPC says after the exchange * @param gives id of the item handed back; nullable * @param consumes id of the item taken from the player; nullable + * @param requires conditions that must hold; nullable + * @param effects effects applied after a successful exchange; nullable */ public record ReactionDto( String onReceive, String response, String gives, - String consumes + String consumes, + List requires, + List effects ) { + /** Backward-compatible constructor without requires/effects. */ + public ReactionDto(String onReceive, String response, String gives, String consumes) { + this(onReceive, response, gives, consumes, null, null); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java index 8751327..59afb55 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java @@ -4,6 +4,8 @@ import lombok.Builder; import lombok.Getter; import thb.jeanluc.adventure.model.item.Item; +import java.util.List; + /** * A single trigger/response pair on an NPC. When the player gives the * NPC an item matching {@link #consumes}, the NPC writes {@link #response} @@ -21,4 +23,10 @@ public class NpcReaction { /** Text the NPC says after a successful exchange. */ private final String response; + + /** Conditions that must hold for the exchange; null/empty = always. */ + private final List requires; + + /** Effects applied after a successful exchange; null = none. */ + private final List effects; } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/ReactionEffectsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/ReactionEffectsTest.java new file mode 100644 index 0000000..2e99a17 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/ReactionEffectsTest.java @@ -0,0 +1,42 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Condition; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.NpcReaction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReactionEffectsTest { + @Test + void reactionRequiresFlagAndAppliesEffects() { + Room room = new Room("k", "K", "d"); + Npc man = Npc.shell("man", "Old Man", "stooped", "hi"); + man.putReaction("fuse", NpcReaction.builder() + .requires(List.of(new Condition(Condition.Type.FLAG, "cellar_drained"))) + .effects(List.of(new Effect(Effect.Type.SET_FLAG, "fuse_installed"))) + .response("Done.") + .build()); + room.addNpc(man); + Player player = new Player(room, 0); + player.addItem(PlainItem.builder().id("fuse").name("Fuse").description("d").build()); + TestIO io = new TestIO(); + GameContext ctx = new GameContext(null, player, io); + + new GiveCommand().execute(ctx, List.of("fuse", "man")); + assertThat(ctx.getState().isSet("fuse_installed")).isFalse(); // requires not met + + ctx.getState().set("cellar_drained"); + new GiveCommand().execute(ctx, List.of("fuse", "man")); + assertThat(ctx.getState().isSet("fuse_installed")).isTrue(); + assertThat(io.allOutput()).contains("Done."); + } +} From acaae3c4f752dc8b8328e9616138d4f19ccaf982 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 21:59:46 +0200 Subject: [PATCH 09/10] feat: switchable items apply effects when turned on Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanluc/adventure/loader/ItemFactory.java | 1 + .../jeanluc/adventure/loader/dto/ItemDto.java | 11 +++++++- .../adventure/model/item/SwitchableItem.java | 10 +++++++ .../model/item/SwitchEffectsTest.java | 28 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/SwitchEffectsTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java index 1703c45..1bcefe0 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java @@ -44,6 +44,7 @@ public final class ItemFactory { .state(Boolean.TRUE.equals(dto.initialState())) .onText(dto.onText()) .offText(dto.offText()) + .effects(thb.jeanluc.adventure.loader.dto.EffectDto.toModelList(dto.effects())) .build(); default -> throw new WorldLoadException( "Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'"); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java index 8139f8c..24ec7f1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java @@ -1,5 +1,7 @@ package thb.jeanluc.adventure.loader.dto; +import java.util.List; + /** * YAML representation of a single item. Fields outside of the type's * scope (e.g. {@code readText} on a switchable) are simply ignored. @@ -12,6 +14,7 @@ package thb.jeanluc.adventure.loader.dto; * @param initialState initial on/off state of a switchable item * @param onText message printed when a switchable transitions to on * @param offText message printed when a switchable transitions to off + * @param effects effects applied when a switchable transitions to on; nullable */ public record ItemDto( String type, @@ -21,6 +24,12 @@ public record ItemDto( String readText, Boolean initialState, String onText, - String offText + String offText, + List effects ) { + /** Backward-compatible constructor without effects. */ + public ItemDto(String type, String id, String name, String description, + String readText, Boolean initialState, String onText, String offText) { + this(type, id, name, description, readText, initialState, onText, offText, null); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java index e3b7684..8f8e1a3 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java @@ -2,7 +2,11 @@ package thb.jeanluc.adventure.model.item; import lombok.Getter; import lombok.experimental.SuperBuilder; +import thb.jeanluc.adventure.game.Effects; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Effect; + +import java.util.List; /** * Item with two boolean states (on / off). Each {@code use} toggles the @@ -24,6 +28,9 @@ public class SwitchableItem extends Item { */ private boolean state; + /** Effects applied when this item transitions to on; null = none. */ + private final List effects; + /** * Toggles the state and writes the corresponding transition text. * @@ -33,6 +40,9 @@ public class SwitchableItem extends Item { public void use(GameContext ctx) { state = !state; ctx.getIo().write(state ? onText : offText); + if (state) { + Effects.applyAll(effects, ctx); + } } /** diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/SwitchEffectsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/SwitchEffectsTest.java new file mode 100644 index 0000000..b468546 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/SwitchEffectsTest.java @@ -0,0 +1,28 @@ +package thb.jeanluc.adventure.model.item; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Effect; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class SwitchEffectsTest { + @Test + void switchingOnAppliesEffects() { + SwitchableItem breaker = SwitchableItem.builder() + .id("breaker").name("Breaker").description("d") + .state(false).onText("hums to life").offText("off") + .effects(List.of(new Effect(Effect.Type.SET_FLAG, "power_on"))) + .build(); + Player player = new Player(new Room("k", "K", "d"), 0); + GameContext ctx = new GameContext(null, player, new TestIO()); + + breaker.use(ctx); + assertThat(ctx.getState().isSet("power_on")).isTrue(); + } +} From 93a5da3af7317dec8a795e66ed2938bf51e568d9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 22:01:57 +0200 Subject: [PATCH 10/10] feat(content): demo the state engine (generator unlocks/lights the cellar) Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/docs/enhancement-ideas.md | 8 ++++++++ .../src/main/resources/world/items.yaml | 12 ++++++++++++ Semesterprojekt/src/main/resources/world/npcs.yaml | 5 +++++ .../src/main/resources/world/rooms.yaml | 14 ++++++++++++-- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index f004757..f48c541 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -78,6 +78,14 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr. ### 5. Quest-System (NPC-Ausbau) +> ✅ 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. + + - Aktuelles NPC-System: Begrüßung + Geschenk-Reaktion (Item → Item). - Ausbau Richtung: mehrstufige Quests, Bedingungen, NPC-"Memory" (Zustand), Quest-Log, evtl. Win-Condition daran gekoppelt. diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml index 06d0650..70a52d5 100644 --- a/Semesterprojekt/src/main/resources/world/items.yaml +++ b/Semesterprojekt/src/main/resources/world/items.yaml @@ -22,3 +22,15 @@ id: key name: Brass Key description: A small brass key, polished from use. + +- type: switchable + id: generator + name: Generator + description: A rusty generator with a heavy lever. + initialState: false + onText: | + You heave the lever. Somewhere deep in the manor, the lights flicker on. + offText: | + You pull the lever back; the manor falls dark again. + effects: + - { setFlag: power_on } diff --git a/Semesterprojekt/src/main/resources/world/npcs.yaml b/Semesterprojekt/src/main/resources/world/npcs.yaml index 5d9b04c..2abdb34 100644 --- a/Semesterprojekt/src/main/resources/world/npcs.yaml +++ b/Semesterprojekt/src/main/resources/world/npcs.yaml @@ -10,3 +10,8 @@ "Thank you! Here, take this key." gives: key consumes: lamp + dialogue: + - when: [{ flag: power_on }] + text: | + "You got the power running! The whole house feels less... + watchful now." diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml index 4521903..2ce13cb 100644 --- a/Semesterprojekt/src/main/resources/world/rooms.yaml +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -9,6 +9,11 @@ south: dungeon items: [letter, lamp] npcs: [old_man] + exitLocks: + - direction: east + requires: [{ flag: power_on }] + blocked: | + The cellar door is held shut by a magnetic lock — dead without power. - id: hallway name: Dark Hallway @@ -40,12 +45,17 @@ west: kitchen items: [] npcs: [] + descriptionStates: + - when: [{ flag: power_on }] + text: | + With the power back, a bare bulb buzzes overhead, revealing + shelves of dusty jars along the far wall. - id: dungeon name: Dungeon description: | - A dark, damp room. + A dark, damp room. A rusty generator squats in the corner. exits: north: kitchen - items: [] + items: [generator] npcs: []