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