Merge use X on Y item combinations into develop

Data-driven combination recipes (combinations.yaml: requires/consume/produce/
effects/response/failText), operands resolved from inventory or current room,
order-independent canonical pair key. UseCommand routes 2+ args to a new
Combinations engine; no parser change (on/with/to already fillers). Loader
validates ids + rejects self-combination/duplicate pairs at load. Demo:
matches + torch -> lit_torch (a working light source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:48:20 +02:00
20 changed files with 644 additions and 12 deletions

View File

@@ -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.

View File

@@ -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 <item-id>}.
* 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 <item> on <item>}), delegates to the
* combination engine. Usage: {@code use <item>} or {@code use <item> on <item>}.
*/
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> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) {
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
@Override
public String help() {
return "use <item> - use an item (read it, switch it on/off, ...)";
return "use <item> - use an item; or use <item> on <item> to combine them";
}
}

View File

@@ -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.";
}
}

View File

@@ -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<String, Item> 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<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 + "')");
}
}
}

View File

@@ -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<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
List<CombinationDto> 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<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);
}
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);

View File

@@ -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<ConditionDto> requires,
List<String> consume,
String produce,
List<EffectDto> effects,
String response,
String failText
) {
}

View File

@@ -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<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);
}
}

View File

@@ -37,15 +37,24 @@ public class World {
/** Ordered list of endings (first matching one wins). */
private final List<Ending> endings;
/** Backward-compatible constructor for worlds without quests or endings. */
/** Global lookup of combination recipes by canonical pair key. */
private final Map<String, Combination> combinations;
/** 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());
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<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());
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());
}
}

View File

@@ -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.

View File

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

View File

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

View File

@@ -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<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");
}
}

View File

@@ -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<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 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();
}
}

View File

@@ -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<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");
}
@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");
}
}

View File

@@ -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();
}
}

View File

@@ -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");

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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.

View File

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