diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 8a19c7e..94e6b11 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -129,6 +129,12 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei. 4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert werden (jetzt erlaubt, da über MVP hinaus). + > ✅ 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ängiger Paar-Key. Kein + > Parser-Eingriff (`on`/`with`/`to` sind bereits Filler). Selbst-Kombination + > (a==b) wird beim Laden abgelehnt. Demo: `matches` + `torch` → `lit_torch` + > (dauerhafte Lichtquelle). 5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende. diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java index 642d0d6..3f08406 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java @@ -1,6 +1,7 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Combinations; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.model.item.Item; @@ -8,9 +9,10 @@ import java.util.List; import java.util.Optional; /** - * Invokes the item-specific {@link Item#use(GameContext)} action. The - * item must either be in the player's inventory or in the current room. - * Usage: {@code use }. + * With one argument, invokes the item-specific {@link Item#use(GameContext)} + * action; the item must be in the player's inventory or the current room. + * With two arguments ({@code use on }), delegates to the + * combination engine. Usage: {@code use } or {@code use on }. */ public class UseCommand implements Command { @@ -20,6 +22,10 @@ public class UseCommand implements Command { ctx.getIo().write("Use what?"); return; } + if (args.size() >= 2) { + Combinations.tryUse(ctx, args.get(0), args.get(1)); + return; + } String itemId = args.getFirst(); Optional item = ctx.getPlayer().findItem(itemId); if (item.isEmpty()) { @@ -34,6 +40,6 @@ public class UseCommand implements Command { @Override public String help() { - return "use - use an item (read it, switch it on/off, ...)"; + return "use - use an item; or use on to combine them"; } } 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..01f5ed1 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Combinations.java @@ -0,0 +1,69 @@ +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); + if (c.response() != null && !c.response().isBlank()) { + 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/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java new file mode 100644 index 0000000..296b4bd --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java @@ -0,0 +1,53 @@ +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.a().equals(dto.b())) { + throw new WorldLoadException( + "Combination cannot combine an item with itself: '" + dto.a() + "'"); + } + 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 + "')"); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java index b5c0eb1..c2ee7ef 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java @@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.type.CollectionType; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.loader.dto.CombinationDto; import thb.jeanluc.adventure.loader.dto.EndingDto; import thb.jeanluc.adventure.loader.dto.GameDto; import thb.jeanluc.adventure.loader.dto.ItemDto; import thb.jeanluc.adventure.loader.dto.NpcDto; import thb.jeanluc.adventure.loader.dto.QuestDto; import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Combination; import thb.jeanluc.adventure.model.Ending; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Player; @@ -75,6 +77,8 @@ public class WorldLoader { List roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class); List questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class); List endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class); + List combinationDtos = + readListOptional(basePath + "/combinations.yaml", CombinationDto.class); GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class); requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList()); @@ -104,6 +108,16 @@ public class WorldLoader { endings.add(EndingFactory.fromDto(dto)); } + 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); + } + ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms); resolver.resolveRooms(roomDtos); resolver.resolveNpcs(npcDtos); @@ -115,7 +129,7 @@ public class WorldLoader { Player player = new Player(start, gold); World world = new World(rooms, items, npcs, - gameDto.title(), gameDto.welcomeMessage(), quests, endings); + gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations); log.info("World '{}' loaded: {} rooms, {} items, {} npcs", gameDto.title(), rooms.size(), items.size(), npcs.size()); return new LoadResult(world, player); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java new file mode 100644 index 0000000..8541dab --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java @@ -0,0 +1,16 @@ +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 +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Combination.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Combination.java new file mode 100644 index 0000000..c9c0b1c --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Combination.java @@ -0,0 +1,35 @@ +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); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java index 56dffb1..c8c9054 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java @@ -37,15 +37,24 @@ public class World { /** Ordered list of endings (first matching one wins). */ private final List endings; - /** Backward-compatible constructor for worlds without quests or endings. */ + /** Global lookup of combination recipes by canonical pair key. */ + private final Map combinations; + + /** 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()); + this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of()); } - /** Backward-compatible constructor for worlds with quests but no endings. */ + /** 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()); + 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()); } } diff --git a/Semesterprojekt/src/main/resources/world/combinations.yaml b/Semesterprojekt/src/main/resources/world/combinations.yaml new file mode 100644 index 0000000..3bde52e --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/combinations.yaml @@ -0,0 +1,7 @@ +- 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. diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml index 1320cdb..1e77b41 100644 --- a/Semesterprojekt/src/main/resources/world/items.yaml +++ b/Semesterprojekt/src/main/resources/world/items.yaml @@ -47,3 +47,19 @@ You ease the door shut again. effects: - { setFlag: fled } + +- 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 diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml index dd9226c..c6d06e0 100644 --- a/Semesterprojekt/src/main/resources/world/rooms.yaml +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -23,7 +23,7 @@ exits: south: kitchen west: library - items: [] + items: [torch] npcs: [] - id: library @@ -33,7 +33,7 @@ An old chest stands in the corner. exits: east: hallway - items: [shovel] + items: [shovel, matches] npcs: [] - id: cellar diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java new file mode 100644 index 0000000..65550d1 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java @@ -0,0 +1,65 @@ +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"); + } +} 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..910446e --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java @@ -0,0 +1,155 @@ +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 effectsOnlyRecipeAppliesEffectsWithoutProduceOrResponse() { + Combination effectsOnly = new Combination("matches", "shovel", + java.util.List.of(), java.util.List.of(), null, + java.util.List.of(new Effect(Effect.Type.SET_FLAG, "rite_done")), + null, null); + GameContext ctx = ctx(effectsOnly); + give(ctx, "matches"); + give(ctx, "shovel"); + Combinations.tryUse(ctx, "matches", "shovel"); + assertThat(ctx.getState().isSet("rite_done")).isTrue(); + assertThat(out(ctx)).doesNotContain("null"); + } + + @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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java new file mode 100644 index 0000000..afa1723 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java @@ -0,0 +1,69 @@ +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"); + } + + @Test + void rejectsUnknownConsumeId() { + CombinationDto dto = new CombinationDto("matches", "torch", null, + List.of("matches", "wraith"), null, null, "x", null); + assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items())) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("wraith"); + } + + @Test + void rejectsSelfCombination() { + CombinationDto dto = new CombinationDto("torch", "torch", null, null, null, null, "x", null); + assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items())) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("itself"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java new file mode 100644 index 0000000..260b36b --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java @@ -0,0 +1,33 @@ +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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java index dc7055d..9eba348 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java @@ -14,7 +14,8 @@ class WorldLoaderTest { assertThat(result.world().getTitle()).isEqualTo("Test Manor"); assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway"); - assertThat(result.world().getItems().keySet()).containsExactlyInAnyOrder("letter", "lamp", "key"); + assertThat(result.world().getItems().keySet()) + .containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch"); assertThat(result.world().getNpcs().keySet()).containsExactly("old_man"); assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen"); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/CombinationTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/CombinationTest.java new file mode 100644 index 0000000..a42e412 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/CombinationTest.java @@ -0,0 +1,26 @@ +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; + +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"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java new file mode 100644 index 0000000..84f22e7 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java @@ -0,0 +1,29 @@ +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"); + } +} diff --git a/Semesterprojekt/src/test/resources/world/combinations.yaml b/Semesterprojekt/src/test/resources/world/combinations.yaml new file mode 100644 index 0000000..3bde52e --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/combinations.yaml @@ -0,0 +1,7 @@ +- 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. diff --git a/Semesterprojekt/src/test/resources/world/items.yaml b/Semesterprojekt/src/test/resources/world/items.yaml index eae04c9..a082c4a 100644 --- a/Semesterprojekt/src/test/resources/world/items.yaml +++ b/Semesterprojekt/src/test/resources/world/items.yaml @@ -17,3 +17,19 @@ id: key name: Key description: A key. + +- type: plain + id: matches + name: Box of Matches + description: A small box of dry matches. + +- type: plain + id: torch + name: Unlit Torch + description: A pitch-soaked torch. + +- type: plain + id: lit_torch + name: Lit Torch + description: A burning torch. + light: true