From ae7f9d6b05fa0f0f33637cf68a3fc611a1abb9a6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 1 Jun 2026 17:22:16 +0200 Subject: [PATCH] feat(game): Combinations engine for use X on Y Co-Authored-By: Claude Sonnet 4.6 --- .../jeanluc/adventure/game/Combinations.java | 67 +++++++++ .../adventure/game/CombinationsTest.java | 141 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java new file mode 100644 index 0000000..d34108a --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java @@ -0,0 +1,67 @@ +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."; + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java new file mode 100644 index 0000000..16fb1e6 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java @@ -0,0 +1,141 @@ +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(); + } +}