feat(game): Combinations engine for use X on Y

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:22:16 +02:00
parent 2747659800
commit ae7f9d6b05
2 changed files with 208 additions and 0 deletions

View File

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

View File

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