Files
Jander_Semester2/Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md
Jean-Luc Makiola 2e48ec7221 docs: implementation plan for use X on Y combinations
7 TDD tasks: Combination model+key, World.combinations, CombinationDto+Factory
(id validation), Combinations engine, UseCommand routing, loader wiring + demo
(matches+torch -> lit_torch), docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:06:35 +02:00

33 KiB

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:

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:

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<Condition> requires,
        List<String> consume,
        String produce,
        List<Effect> 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
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:

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:

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:

    /** Global lookup of combination recipes by canonical pair key. */
    private final Map<String, Combination> 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:

    /** Backward-compatible constructor for worlds without quests, endings, or combinations. */
    public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> 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<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
                 String title, String welcomeMessage, Map<String, Quest> 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<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
                 String title, String welcomeMessage, Map<String, Quest> quests, List<Ending> 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
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:

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<String, Item> items() {
        Map<String, Item> 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:

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<ConditionDto> requires,
        List<String> consume,
        String produce,
        List<EffectDto> effects,
        String response,
        String failText
) {
}
  • Step 4: Create CombinationFactory

src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.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<String, Item> 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<String, Item> 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
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:

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<String, Item> 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<String, Combination> 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:

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

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<String, Item> 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:

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

        if (args.size() >= 2) {
            Combinations.tryUse(ctx, args.get(0), args.get(1));
            return;
        }

Also update the help() text:

    @Override
    public String help() {
        return "use <item>  -  use an item; or  use <item> on <item>  to combine them";
    }
  • Step 4: Run tests to verify they pass

Run: mvn -q test -Dtest=UseCommandComboTest Expected: PASS (3 tests).

  • Step 5: Commit
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:

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:

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

        List<CombinationDto> combinationDtos =
                readListOptional(basePath + "/combinations.yaml", CombinationDto.class);

After the endings list is built (and after items is populated), build the combinations map:

        Map<String, Combination> 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:

        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:


- 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:
  items: [shovel, matches]
  • Change the hallway room's items line from items: [] to:
  items: [torch]
  • Step 6: Create combinations.yaml

src/main/resources/world/combinations.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
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 + torchlit_torch (dauerhafte Lichtquelle).

  • Step 2: Commit
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.