Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-05-31-quest-foundation.md
2026-05-31 21:44:37 +02:00

1384 lines
44 KiB
Markdown

# 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<String, Item> 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<String> 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<String> 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<Condition> 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<Effect> 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<Condition> toModelList(List<ConditionDto> dtos) {
List<Condition> 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<Effect> toModelList(List<EffectDto> dtos) {
List<Effect> 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<Condition> 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<Direction, ExitLock> 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<ConditionDto> requires, String blocked) {
}
```
In `RoomDto.java`, add the field as the last record component:
```java
List<String> npcs,
List<ExitLockDto> exitLocks
```
(append `, List<ExitLockDto> 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<Condition> 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<DescriptionState> 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<ConditionDto> when, String text) {
}
```
In `RoomDto.java`, append a new component after `exitLocks`:
```java
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> 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<Condition> 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<DialogueLine> 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<ConditionDto> when, String text) {
}
```
In `NpcDto.java`, append a component after `reactions`:
```java
List<ReactionDto> reactions,
List<DialogueLineDto> 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<Condition> requires;
/** Effects applied after a successful exchange; null = none. */
private final List<Effect> effects;
```
- [ ] **Step 4: Add `requires`/`effects` to `ReactionDto.java`**
Append two components:
```java
String consumes,
List<ConditionDto> requires,
List<EffectDto> 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<Effect> 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<EffectDto> 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<Condition>, ctx)`, `Effects.applyAll(List<Effect>, 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.