fix(combinations): guard null response; reject self-combination at load

- Combinations.tryUse: only write response when non-null and non-blank,
  preventing NPE/IllegalArgument for effects-only recipes.
- CombinationFactory.fromDto: throw WorldLoadException when a == b,
  since item singletons make a self-combination impossible at runtime.
- Tests: +effectsOnlyRecipeAppliesEffectsWithoutProduceOrResponse
  (CombinationsTest 7→8), +rejectsSelfCombination
  (CombinationFactoryTest 4→5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 17:28:32 +02:00
parent ae7f9d6b05
commit 6eabebff00
4 changed files with 29 additions and 1 deletions

View File

@@ -47,8 +47,10 @@ public final class Combinations {
}
}
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)

View File

@@ -18,6 +18,10 @@ public final class 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");

View File

@@ -123,6 +123,20 @@ class CombinationsTest {
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",

View File

@@ -58,4 +58,12 @@ class CombinationFactoryTest {
.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");
}
}