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

44 KiB

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


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:

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:

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:

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:

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:

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:

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:

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:

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):

    /** 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
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:

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:

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:

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
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:

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
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:

import java.util.List;

Add the field next to exits:

    /** Optional condition-gates per exit direction. */
    private final EnumMap<Direction, ExitLock> exitLocks = new EnumMap<>(Direction.class);

Add the mutator (near addExit):

    /**
     * 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:

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:

        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:

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:

            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:

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:

        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
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:

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
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:

import java.util.ArrayList;

Add the field:

    /** Optional condition-gated description variants, first match wins. */
    private final List<DescriptionState> descriptionStates = new ArrayList<>();

Add the mutator:

    /**
     * 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:

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:

        List<ExitLockDto> exitLocks,
        List<DescriptionStateDto> descriptionStates

(and the matching @param descriptionStates Javadoc line).

  • Step 6: Wire in ReferenceResolver.resolveRooms

Add imports:

import thb.jeanluc.adventure.loader.dto.DescriptionStateDto;
import thb.jeanluc.adventure.model.DescriptionState;

After the exitLocks block in the room loop, add:

            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:

import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.model.DescriptionState;

Replace the RoomView construction so the description is resolved first. Change:

        ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits));

to:

        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
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:

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
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:

import lombok.Builder;
import java.util.ArrayList;
import java.util.List;

Add the field (with @Builder.Default so every builder path initialises it):

    /** Optional condition-gated dialogue lines, first match wins; else {@link #greeting}. */
    @Builder.Default
    private final List<DialogueLine> dialogue = new ArrayList<>();

Add the mutator:

    /**
     * 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:

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:

        List<ReactionDto> reactions,
        List<DialogueLineDto> dialogue

(and the @param dialogue Javadoc line).

  • Step 6: Wire dialogue in ReferenceResolver.resolveNpcs

Add imports:

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:

            if (dto.reactions() == null) {
                continue;
            }
            Npc npc = npcs.get(dto.id());
            for (ReactionDto r : dto.reactions()) {

to:

            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:

import thb.jeanluc.adventure.game.Conditions;
import thb.jeanluc.adventure.model.DialogueLine;

Replace:

        ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting());

with:

        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
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:

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:

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:

    /** 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:

        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:

import thb.jeanluc.adventure.loader.dto.EffectDto;

Extend the NpcReaction.builder() call to set the new fields:

                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:

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:

        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
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:

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:

import thb.jeanluc.adventure.game.Effects;
import thb.jeanluc.adventure.model.Effect;
import java.util.List;

Add the field:

    /** Effects applied when this item transitions to on; null = none. */
    private final List<Effect> effects;

Replace use with:

    @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:

        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():

            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
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:

- 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:

  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:

  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:

  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:

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:

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