# 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.