diff --git a/Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md b/Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md new file mode 100644 index 0000000..d5cce61 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md @@ -0,0 +1,957 @@ +# `use X on Y` Item Combinations 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 data-driven `use X on Y` item-combination mechanic: recipes that optionally require conditions, consume items, produce an item, apply effects, and print a response — operands resolved from inventory or the current room. + +**Architecture:** A new `Combination` domain record + optional `combinations.yaml` loaded like quests/endings into `World.combinations` (a `Map` keyed by an order-independent pair key). A stateless `Combinations` engine (alongside `Conditions`/`Effects`/`Light`) performs resolution, lookup, and application. `UseCommand` routes 2+ args to the engine; 1 arg keeps the existing single-item behaviour. No parser change — `on`/`with`/`to` are already filler words. + +**Tech Stack:** Java 25, Maven, Jackson (YAML loader), JUnit 5 + AssertJ, Lombok. + +--- + +## File Structure + +**Create:** +- `src/main/java/thb/jeanluc/adventure/model/Combination.java` — domain record + canonical pair key. +- `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java` — YAML DTO. +- `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java` — DTO → domain, validates item ids exist. +- `src/main/java/thb/jeanluc/adventure/game/Combinations.java` — combination engine. +- `src/main/resources/world/combinations.yaml` — demo recipe. + +**Modify:** +- `model/World.java` — add `combinations` field + backward-compatible constructors + getter. +- `loader/WorldLoader.java` — read `combinations.yaml`, build the map (dup-key check), pass to `World`. +- `command/impl/UseCommand.java` — route 2+ args to `Combinations.tryUse`. +- `src/main/resources/world/items.yaml` — add `matches`, `torch`, `lit_torch`. +- `src/main/resources/world/rooms.yaml` — place `matches` (library) and `torch` (hallway). + +--- + +## Task 1: `Combination` domain model + canonical key + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/model/Combination.java` +- Test: `src/test/java/thb/jeanluc/adventure/model/CombinationTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`: +```java +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CombinationTest { + + @Test + void keyIsOrderIndependentAndSorted() { + assertThat(Combination.key("matches", "torch")) + .isEqualTo(Combination.key("torch", "matches")) + .isEqualTo("matches|torch"); + assertThat(Combination.key("b", "a")).isEqualTo("a|b"); + } + + @Test + void nullCollectionComponentsBecomeEmpty() { + Combination c = new Combination("a", "b", null, null, null, null, "ok", null); + assertThat(c.requires()).isEmpty(); + assertThat(c.consume()).isEmpty(); + assertThat(c.effects()).isEmpty(); + assertThat(c.produce()).isNull(); + assertThat(c.key()).isEqualTo("a|b"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=CombinationTest` +Expected: FAIL — `Combination` does not exist. + +- [ ] **Step 3: Create `Combination`** + +`src/main/java/thb/jeanluc/adventure/model/Combination.java`: +```java +package thb.jeanluc.adventure.model; + +import java.util.List; + +/** + * A data-driven item-combination recipe. Identified by an unordered pair of + * item ids ({@code a}, {@code b}). When applied it may require conditions, + * consume items, produce an item, apply effects, and print a response. + */ +public record Combination( + String a, + String b, + List requires, + List consume, + String produce, + List effects, + String response, + String failText +) { + public Combination { + requires = requires == null ? List.of() : List.copyOf(requires); + consume = consume == null ? List.of() : List.copyOf(consume); + effects = effects == null ? List.of() : List.copyOf(effects); + } + + /** Order-independent key for an item pair (sorted, '|'-joined). */ + public static String key(String x, String y) { + return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x; + } + + /** This recipe's canonical pair key. */ + public String key() { + return key(a, b); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -q test -Dtest=CombinationTest` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/Combination.java \ + src/test/java/thb/jeanluc/adventure/model/CombinationTest.java +git commit -m "feat(model): Combination recipe record + order-independent key" +``` + +--- + +## Task 2: `World.combinations` field + constructors + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/model/World.java` +- Test: `src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`: +```java +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class WorldCombinationsTest { + + @Test + void legacyConstructorsDefaultToEmptyCombinations() { + World w5 = new World(Map.of(), Map.of(), Map.of(), "t", "w"); + World w6 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of()); + World w7 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), List.of()); + assertThat(w5.getCombinations()).isEmpty(); + assertThat(w6.getCombinations()).isEmpty(); + assertThat(w7.getCombinations()).isEmpty(); + } + + @Test + void fullConstructorStoresCombinations() { + Combination c = new Combination("a", "b", List.of(), List.of(), null, List.of(), "r", null); + World w = new World(Map.of(), Map.of(), Map.of(), "t", "w", + Map.of(), List.of(), Map.of(c.key(), c)); + assertThat(w.getCombinations()).containsKey("a|b"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=WorldCombinationsTest` +Expected: FAIL — no `getCombinations()` / no 8-arg constructor. + +- [ ] **Step 3: Add the field, constructors, and import** + +In `src/main/java/thb/jeanluc/adventure/model/World.java`: + +Add the import near the other model imports: +```java +import thb.jeanluc.adventure.model.Combination; +``` +(If the file is in package `thb.jeanluc.adventure.model`, `Combination` is same-package — skip the import; reference it directly.) + +Add the field immediately after the `endings` field: +```java + /** Global lookup of combination recipes by canonical pair key. */ + private final Map combinations; +``` + +Update the existing 5-arg and 6-arg convenience constructors and add a 7-arg one, so all delegate to the Lombok-generated 8-arg constructor: +```java + /** Backward-compatible constructor for worlds without quests, endings, or combinations. */ + public World(Map rooms, Map items, Map npcs, + String title, String welcomeMessage) { + this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of()); + } + + /** Backward-compatible constructor for worlds with quests but no endings/combinations. */ + public World(Map rooms, Map items, Map npcs, + String title, String welcomeMessage, Map quests) { + this(rooms, items, npcs, title, welcomeMessage, quests, List.of(), Map.of()); + } + + /** Backward-compatible constructor for worlds with quests + endings but no combinations. */ + public World(Map rooms, Map items, Map npcs, + String title, String welcomeMessage, Map quests, List endings) { + this(rooms, items, npcs, title, welcomeMessage, quests, endings, Map.of()); + } +``` +(The `@RequiredArgsConstructor` now generates the canonical 8-arg constructor `(rooms, items, npcs, title, welcomeMessage, quests, endings, combinations)`, and `@Getter` provides `getCombinations()`.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=WorldCombinationsTest,QuestEngineTest` +Expected: PASS (QuestEngineTest uses the 6-arg constructor — still compiles). + +- [ ] **Step 5: Full compile check (all World callers still build)** + +Run: `mvn -q -DskipTests test-compile` +Expected: BUILD SUCCESS (5/6/7-arg call sites in tests + WorldLoader still resolve). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/model/World.java \ + src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java +git commit -m "feat(model): World.combinations field + backward-compatible constructors" +``` + +--- + +## Task 3: `CombinationDto` + `CombinationFactory` (with id validation) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java` +- Create: `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java` +- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java` + +Validation lives in the factory (build-time, with the item map at hand) rather than `WorldValidator`, to avoid changing `WorldValidator`'s constructor (used directly by `WorldValidatorTest`). + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`: +```java +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.dto.CombinationDto; +import thb.jeanluc.adventure.model.Combination; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CombinationFactoryTest { + + private Map items() { + Map m = new HashMap<>(); + for (String id : List.of("matches", "torch", "lit_torch")) { + m.put(id, PlainItem.builder().id(id).name(id).description("d").light(false).build()); + } + return m; + } + + @Test + void buildsCombinationFromDto() { + CombinationDto dto = new CombinationDto("matches", "torch", null, + List.of("matches", "torch"), "lit_torch", null, "You light it.", null); + Combination c = CombinationFactory.fromDto(dto, items()); + assertThat(c.key()).isEqualTo("matches|torch"); + assertThat(c.consume()).containsExactly("matches", "torch"); + assertThat(c.produce()).isEqualTo("lit_torch"); + assertThat(c.response()).isEqualTo("You light it."); + } + + @Test + void rejectsUnknownOperandId() { + CombinationDto dto = new CombinationDto("matches", "ghost", null, null, null, null, "x", null); + assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items())) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("ghost"); + } + + @Test + void rejectsUnknownProduceId() { + CombinationDto dto = new CombinationDto("matches", "torch", null, null, "phantom", null, "x", null); + assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items())) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("phantom"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=CombinationFactoryTest` +Expected: FAIL — `CombinationDto` / `CombinationFactory` do not exist. + +- [ ] **Step 3: Create `CombinationDto`** + +`src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`: +```java +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** YAML representation of an item-combination recipe. */ +public record CombinationDto( + String a, + String b, + List requires, + List consume, + String produce, + List effects, + String response, + String failText +) { +} +``` + +- [ ] **Step 4: Create `CombinationFactory`** + +`src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`: +```java +package thb.jeanluc.adventure.loader; + +import thb.jeanluc.adventure.loader.dto.CombinationDto; +import thb.jeanluc.adventure.loader.dto.ConditionDto; +import thb.jeanluc.adventure.loader.dto.EffectDto; +import thb.jeanluc.adventure.model.Combination; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Map; + +/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */ +public final class CombinationFactory { + + private CombinationFactory() { + } + + public static Combination fromDto(CombinationDto dto, Map items) { + requireItem(items, dto.a(), "a"); + requireItem(items, dto.b(), "b"); + if (dto.consume() != null) { + for (String id : dto.consume()) { + requireItem(items, id, "consume"); + } + } + if (dto.produce() != null) { + requireItem(items, dto.produce(), "produce"); + } + return new Combination( + dto.a(), + dto.b(), + ConditionDto.toModelList(dto.requires()), + dto.consume() == null ? List.of() : List.copyOf(dto.consume()), + dto.produce(), + EffectDto.toModelList(dto.effects()), + dto.response(), + dto.failText()); + } + + private static void requireItem(Map items, String id, String field) { + if (id == null) { + throw new WorldLoadException("Combination '" + field + "' is missing an item id"); + } + if (!items.containsKey(id)) { + throw new WorldLoadException( + "Combination references unknown item '" + id + "' (field '" + field + "')"); + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=CombinationFactoryTest` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java \ + src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java \ + src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java +git commit -m "feat(loader): CombinationDto + CombinationFactory with id validation" +``` + +--- + +## Task 4: `Combinations` engine + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/game/Combinations.java` +- Test: `src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`: +```java +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Combination; +import thb.jeanluc.adventure.model.Condition; +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.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class CombinationsTest { + + private Item plain(String id, boolean light) { + return PlainItem.builder().id(id).name(id).description("d").light(light).build(); + } + + /** World with matches/torch/lit_torch/shovel and the given recipes; player in an empty room. */ + private GameContext ctx(Combination... recipes) { + Map items = new LinkedHashMap<>(); + items.put("matches", plain("matches", false)); + items.put("torch", plain("torch", false)); + items.put("lit_torch", plain("lit_torch", true)); + items.put("shovel", plain("shovel", false)); + Map combos = new HashMap<>(); + for (Combination c : recipes) { + combos.put(c.key(), c); + } + Room start = new Room("start", "Start", "d"); + World w = new World(Map.of("start", start), items, Map.of(), "t", "w", + Map.of(), List.of(), combos); + return new GameContext(w, new Player(start, 0), new TestIO()); + } + + private void give(GameContext ctx, String id) { + ctx.getPlayer().addItem(ctx.getWorld().getItems().get(id)); + } + + private void putInRoom(GameContext ctx, String id) { + ctx.getPlayer().getCurrentRoom().addItem(ctx.getWorld().getItems().get(id)); + } + + private String out(GameContext ctx) { + return ((TestIO) ctx.getIo()).allOutput(); + } + + private Combination torchRecipe() { + return new Combination("matches", "torch", List.of(), + List.of("matches", "torch"), "lit_torch", List.of(), + "You light the torch.", null); + } + + @Test + void combinesFromInventory() { + GameContext ctx = ctx(torchRecipe()); + give(ctx, "matches"); + give(ctx, "torch"); + Combinations.tryUse(ctx, "matches", "torch"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue(); + assertThat(ctx.getPlayer().hasItem("matches")).isFalse(); + assertThat(ctx.getPlayer().hasItem("torch")).isFalse(); + assertThat(out(ctx)).contains("light the torch"); + } + + @Test + void matchIsOrderIndependent() { + GameContext ctx = ctx(torchRecipe()); + give(ctx, "matches"); + give(ctx, "torch"); + Combinations.tryUse(ctx, "torch", "matches"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue(); + } + + @Test + void consumesOperandFromCurrentRoom() { + GameContext ctx = ctx(torchRecipe()); + give(ctx, "matches"); + putInRoom(ctx, "torch"); + Combinations.tryUse(ctx, "matches", "torch"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue(); + assertThat(ctx.getPlayer().getCurrentRoom().findItem("torch")).isEmpty(); + } + + @Test + void missingOperandReportsNoSuch() { + GameContext ctx = ctx(torchRecipe()); + give(ctx, "matches"); // torch absent + Combinations.tryUse(ctx, "matches", "torch"); + assertThat(out(ctx)).contains("no 'torch'"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse(); + } + + @Test + void noRecipeForPairSaysNothingHappens() { + GameContext ctx = ctx(torchRecipe()); + give(ctx, "shovel"); + give(ctx, "torch"); + Combinations.tryUse(ctx, "shovel", "torch"); + assertThat(out(ctx)).contains("Nothing happens."); + } + + @Test + void unmetRequiresShowsFailTextAndDoesNotProduce() { + Combination gated = new Combination("matches", "torch", + List.of(new Condition(Condition.Type.FLAG, "dry")), + List.of("matches", "torch"), "lit_torch", List.of(), + "It catches.", "The torch is too damp to light."); + GameContext ctx = ctx(gated); + give(ctx, "matches"); + give(ctx, "torch"); + Combinations.tryUse(ctx, "matches", "torch"); + assertThat(out(ctx)).contains("too damp"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse(); + assertThat(ctx.getPlayer().hasItem("matches")).isTrue(); // not consumed + } + + @Test + void metRequiresAppliesEffectsAndProduces() { + Combination gated = new Combination("matches", "torch", + List.of(new Condition(Condition.Type.FLAG, "dry")), + List.of("matches", "torch"), "lit_torch", + List.of(new Effect(Effect.Type.SET_FLAG, "torch_lit")), + "It catches.", "too damp"); + GameContext ctx = ctx(gated); + ctx.getState().set("dry"); + give(ctx, "matches"); + give(ctx, "torch"); + Combinations.tryUse(ctx, "matches", "torch"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue(); + assertThat(ctx.getState().isSet("torch_lit")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=CombinationsTest` +Expected: FAIL — `Combinations` does not exist. + +- [ ] **Step 3: Create `Combinations`** + +`src/main/java/thb/jeanluc/adventure/game/Combinations.java`: +```java +package thb.jeanluc.adventure.game; + +import thb.jeanluc.adventure.model.Combination; +import thb.jeanluc.adventure.model.item.Item; + +/** + * Applies {@code use X on Y} item combinations. Stateless utility, mirroring + * {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the + * player's inventory or the current room. + */ +public final class Combinations { + + private Combinations() { + } + + /** + * Attempts to combine the two named items. Prints a resolution error if an + * operand is absent, the recipe's {@code failText} (or a generic line) if a + * recipe's requirements are unmet, "Nothing happens." if there is no recipe, + * or applies consume/produce/effects + the response on success. + */ + public static void tryUse(GameContext ctx, String idX, String idY) { + if (!present(ctx, idX)) { + ctx.getIo().write(noSuch(idX)); + return; + } + if (!present(ctx, idY)) { + ctx.getIo().write(noSuch(idY)); + return; + } + Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY)); + if (c == null) { + ctx.getIo().write("Nothing happens."); + return; + } + if (!Conditions.all(c.requires(), ctx)) { + ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens."); + return; + } + for (String id : c.consume()) { + removeFromAnywhere(ctx, id); + } + if (c.produce() != null) { + Item produced = ctx.getWorld().getItems().get(c.produce()); + if (produced != null) { + ctx.getPlayer().addItem(produced); + } + } + Effects.applyAll(c.effects(), ctx); + ctx.getIo().write(c.response()); + } + + private static boolean present(GameContext ctx, String id) { + return ctx.getPlayer().hasItem(id) + || ctx.getPlayer().getCurrentRoom().findItem(id).isPresent(); + } + + private static void removeFromAnywhere(GameContext ctx, String id) { + if (ctx.getPlayer().removeItem(id).isEmpty()) { + ctx.getPlayer().getCurrentRoom().removeItem(id); + } + } + + private static String noSuch(String id) { + return "There is no '" + id + "' here or in your inventory."; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=CombinationsTest` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/Combinations.java \ + src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java +git commit -m "feat(game): Combinations engine for use X on Y" +``` + +--- + +## Task 5: Route `UseCommand` to combinations + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java` +- Test: `src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.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.Combination; +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 thb.jeanluc.adventure.model.item.SwitchableItem; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class UseCommandComboTest { + + private GameContext ctx() { + Map items = new LinkedHashMap<>(); + items.put("matches", PlainItem.builder().id("matches").name("Matches").description("d").light(false).build()); + items.put("torch", PlainItem.builder().id("torch").name("Torch").description("d").light(false).build()); + items.put("lit_torch", PlainItem.builder().id("lit_torch").name("Lit Torch").description("d").light(true).build()); + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d").light(true) + .onText("The lamp glows.").offText("off").state(false).effects(List.of()).build(); + items.put("lamp", lamp); + Combination recipe = new Combination("matches", "torch", List.of(), + List.of("matches", "torch"), "lit_torch", List.of(), "You light the torch.", null); + Room start = new Room("start", "Start", "d"); + World w = new World(Map.of("start", start), items, Map.of(), "t", "w", + Map.of(), List.of(), Map.of(recipe.key(), recipe)); + GameContext ctx = new GameContext(w, new Player(start, 0), new TestIO()); + ctx.getPlayer().addItem(items.get("matches")); + ctx.getPlayer().addItem(items.get("torch")); + ctx.getPlayer().addItem(lamp); + return ctx; + } + + @Test + void emptyArgsAsksWhat() { + GameContext ctx = ctx(); + new UseCommand().execute(ctx, List.of()); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Use what?"); + } + + @Test + void twoArgsRoutesToCombination() { + GameContext ctx = ctx(); + new UseCommand().execute(ctx, List.of("matches", "torch")); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue(); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("light the torch"); + } + + @Test + void oneArgUsesItemDirectly() { + GameContext ctx = ctx(); + new UseCommand().execute(ctx, List.of("lamp")); + assertThat(((SwitchableItem) ctx.getWorld().getItems().get("lamp")).isOn()).isTrue(); + assertThat(((TestIO) ctx.getIo()).allOutput()).contains("lamp glows"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=UseCommandComboTest` +Expected: FAIL — `twoArgsRoutesToCombination` fails (current `UseCommand` treats only the first arg, no combination). + +- [ ] **Step 3: Add the combination branch to `UseCommand`** + +In `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`: + +Add the import: +```java +import thb.jeanluc.adventure.game.Combinations; +``` + +Insert the two-arg branch in `execute`, right after the empty-args check and before `String itemId = args.getFirst();`: +```java + if (args.size() >= 2) { + Combinations.tryUse(ctx, args.get(0), args.get(1)); + return; + } +``` + +Also update the `help()` text: +```java + @Override + public String help() { + return "use - use an item; or use on to combine them"; + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=UseCommandComboTest` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java \ + src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java +git commit -m "feat(command): route use X on Y to the combination engine" +``` + +--- + +## Task 6: Loader wiring + demo content + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java` +- Modify: `src/main/resources/world/items.yaml` +- Modify: `src/main/resources/world/rooms.yaml` +- Create: `src/main/resources/world/combinations.yaml` +- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`: +```java +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.Combinations; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Combination; + +import static org.assertj.core.api.Assertions.assertThat; + +class CombinationLoadingTest { + + @Test + void realWorldLoadsTheTorchRecipe() { + WorldLoader.LoadResult loaded = new WorldLoader().load(); + assertThat(loaded.world().getCombinations()) + .containsKey(Combination.key("matches", "torch")); + assertThat(loaded.world().getItems()).containsKeys("matches", "torch", "lit_torch"); + } + + @Test + void torchRecipeWorksEndToEnd() { + WorldLoader.LoadResult loaded = new WorldLoader().load(); + GameContext ctx = new GameContext( + new GameSession(loaded.world(), loaded.player(), "test"), new TestIO()); + // grab the demo items straight from the registry, regardless of room + ctx.getPlayer().addItem(ctx.getWorld().getItems().get("matches")); + ctx.getPlayer().addItem(ctx.getWorld().getItems().get("torch")); + Combinations.tryUse(ctx, "matches", "torch"); + assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=CombinationLoadingTest` +Expected: FAIL — no `combinations.yaml`/`matches` items yet; `getCombinations()` is empty. + +- [ ] **Step 3: Wire `WorldLoader`** + +In `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`: + +Add imports: +```java +import thb.jeanluc.adventure.loader.dto.CombinationDto; +import thb.jeanluc.adventure.model.Combination; +``` + +In `load()`, add the read alongside the other optional reads (after the `endingDtos` line): +```java + List combinationDtos = + readListOptional(basePath + "/combinations.yaml", CombinationDto.class); +``` + +After the `endings` list is built (and after `items` is populated), build the combinations map: +```java + Map combinations = new HashMap<>(); + for (CombinationDto dto : combinationDtos) { + Combination c = CombinationFactory.fromDto(dto, items); + String key = c.key(); + if (combinations.containsKey(key)) { + throw new WorldLoadException("Duplicate combination for pair '" + key + "'"); + } + combinations.put(key, c); + } +``` + +Change the `World` construction to the 8-arg form: +```java + World world = new World(rooms, items, npcs, + gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations); +``` + +- [ ] **Step 4: Add demo items to `items.yaml`** + +Append to `src/main/resources/world/items.yaml`: +```yaml + +- type: plain + id: matches + name: Box of Matches + description: A small box of dry matches. They rattle when you shake it. + +- type: plain + id: torch + name: Unlit Torch + description: A pitch-soaked torch, waiting for a flame. + +- type: plain + id: lit_torch + name: Lit Torch + description: A burning torch, throwing back the dark. + light: true +``` + +- [ ] **Step 5: Place demo items in rooms** + +In `src/main/resources/world/rooms.yaml`: +- Change the `library` room's items line from `items: [shovel]` to: +```yaml + items: [shovel, matches] +``` +- Change the `hallway` room's items line from `items: []` to: +```yaml + items: [torch] +``` + +- [ ] **Step 6: Create `combinations.yaml`** + +`src/main/resources/world/combinations.yaml`: +```yaml +- a: matches + b: torch + consume: [matches, torch] + produce: lit_torch + response: | + You strike a match and touch it to the torch; it catches with a low hiss + and burns steady. You now hold a lit torch. +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `mvn -q test -Dtest=CombinationLoadingTest` +Expected: PASS (2 tests). + +- [ ] **Step 8: Full suite (no regressions from new items)** + +Run: `mvn -q clean test` +Expected: BUILD SUCCESS, all tests green. (WorldLoaderTest uses its own test fixture base, not these production resources, so it is unaffected.) + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \ + src/main/resources/world/items.yaml \ + src/main/resources/world/rooms.yaml \ + src/main/resources/world/combinations.yaml \ + src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java +git commit -m "feat(loader): load combinations.yaml; demo matches+torch -> lit_torch" +``` + +--- + +## Task 7: Docs + +**Files:** +- Modify: `docs/enhancement-ideas.md` + +- [ ] **Step 1: Mark the combination mechanic implemented** + +In `docs/enhancement-ideas.md`, under section **5b** (Mechanik-Spine), add a `> ✅` +blockquote note after the bullet for mechanic **4. Item-Kombination**, mirroring +the existing `> ✅ umgesetzt (Branch ...)` style used for light/darkness etc.: + +> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene +> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText), +> Operanden aus Inventar oder Raum, reihenfolge-unabhängig. Kein Parser-Eingriff +> (`on`/`with`/`to` sind bereits Filler). Demo: `matches` + `torch` → `lit_torch` +> (dauerhafte Lichtquelle). + +- [ ] **Step 2: Commit** + +```bash +git add docs/enhancement-ideas.md +git commit -m "docs: mark use X on Y item combination implemented" +``` + +--- + +## Final Verification + +- [ ] `mvn -q clean test` — all tests pass (162 prior + the new combination suites). +- [ ] Manual console smoke (optional): start a game, navigate to the hallway (`take torch`) and library (`take matches`), then `use matches on torch` → "You ... lit torch"; `inventory` shows `lit_torch`, `matches`/`torch` gone. +- [ ] `git status` clean.