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:
@@ -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
|
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
|
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
||||||
werden (jetzt erlaubt, da über MVP hinaus).
|
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
|
5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette
|
||||||
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
||||||
|
|
||||||
|
|||||||
957
Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md
Normal file
957
Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
# `use X on Y` Item Combinations Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a data-driven `use X on Y` item-combination mechanic: recipes that optionally require conditions, consume items, produce an item, apply effects, and print a response — operands resolved from inventory or the current room.
|
||||||
|
|
||||||
|
**Architecture:** A new `Combination` domain record + optional `combinations.yaml` loaded like quests/endings into `World.combinations` (a `Map` keyed by an order-independent pair key). A stateless `Combinations` engine (alongside `Conditions`/`Effects`/`Light`) performs resolution, lookup, and application. `UseCommand` routes 2+ args to the engine; 1 arg keeps the existing single-item behaviour. No parser change — `on`/`with`/`to` are already filler words.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, Jackson (YAML loader), JUnit 5 + AssertJ, Lombok.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/model/Combination.java` — domain record + canonical pair key.
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java` — YAML DTO.
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java` — DTO → domain, validates item ids exist.
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/game/Combinations.java` — combination engine.
|
||||||
|
- `src/main/resources/world/combinations.yaml` — demo recipe.
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `model/World.java` — add `combinations` field + backward-compatible constructors + getter.
|
||||||
|
- `loader/WorldLoader.java` — read `combinations.yaml`, build the map (dup-key check), pass to `World`.
|
||||||
|
- `command/impl/UseCommand.java` — route 2+ args to `Combinations.tryUse`.
|
||||||
|
- `src/main/resources/world/items.yaml` — add `matches`, `torch`, `lit_torch`.
|
||||||
|
- `src/main/resources/world/rooms.yaml` — place `matches` (library) and `torch` (hallway).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Combination` domain model + canonical key
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/model/Combination.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationTest`
|
||||||
|
Expected: FAIL — `Combination` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `Combination`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/model/Combination.java`:
|
||||||
|
```java
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationTest`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/Combination.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/model/CombinationTest.java
|
||||||
|
git commit -m "feat(model): Combination recipe record + order-independent key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `World.combinations` field + constructors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/model/World.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`:
|
||||||
|
```java
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=WorldCombinationsTest`
|
||||||
|
Expected: FAIL — no `getCombinations()` / no 8-arg constructor.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the field, constructors, and import**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/model/World.java`:
|
||||||
|
|
||||||
|
Add the import near the other model imports:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
```
|
||||||
|
(If the file is in package `thb.jeanluc.adventure.model`, `Combination` is same-package — skip the import; reference it directly.)
|
||||||
|
|
||||||
|
Add the field immediately after the `endings` field:
|
||||||
|
```java
|
||||||
|
/** Global lookup of combination recipes by canonical pair key. */
|
||||||
|
private final Map<String, Combination> combinations;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the existing 5-arg and 6-arg convenience constructors and add a 7-arg one, so all delegate to the Lombok-generated 8-arg constructor:
|
||||||
|
```java
|
||||||
|
/** 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(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(), 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());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(The `@RequiredArgsConstructor` now generates the canonical 8-arg constructor `(rooms, items, npcs, title, welcomeMessage, quests, endings, combinations)`, and `@Getter` provides `getCombinations()`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=WorldCombinationsTest,QuestEngineTest`
|
||||||
|
Expected: PASS (QuestEngineTest uses the 6-arg constructor — still compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full compile check (all World callers still build)**
|
||||||
|
|
||||||
|
Run: `mvn -q -DskipTests test-compile`
|
||||||
|
Expected: BUILD SUCCESS (5/6/7-arg call sites in tests + WorldLoader still resolve).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/World.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java
|
||||||
|
git commit -m "feat(model): World.combinations field + backward-compatible constructors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `CombinationDto` + `CombinationFactory` (with id validation)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`
|
||||||
|
|
||||||
|
Validation lives in the factory (build-time, with the item map at hand) rather than `WorldValidator`, to avoid changing `WorldValidator`'s constructor (used directly by `WorldValidatorTest`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`:
|
||||||
|
```java
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationFactoryTest`
|
||||||
|
Expected: FAIL — `CombinationDto` / `CombinationFactory` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `CombinationDto`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`:
|
||||||
|
```java
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `CombinationFactory`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`:
|
||||||
|
```java
|
||||||
|
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.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 + "')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationFactoryTest`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java
|
||||||
|
git commit -m "feat(loader): CombinationDto + CombinationFactory with id validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `Combinations` engine
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/game/Combinations.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`:
|
||||||
|
```java
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationsTest`
|
||||||
|
Expected: FAIL — `Combinations` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `Combinations`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/game/Combinations.java`:
|
||||||
|
```java
|
||||||
|
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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationsTest`
|
||||||
|
Expected: PASS (7 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/game/Combinations.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java
|
||||||
|
git commit -m "feat(game): Combinations engine for use X on Y"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Route `UseCommand` to combinations
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java`:
|
||||||
|
```java
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=UseCommandComboTest`
|
||||||
|
Expected: FAIL — `twoArgsRoutesToCombination` fails (current `UseCommand` treats only the first arg, no combination).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the combination branch to `UseCommand`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`:
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert the two-arg branch in `execute`, right after the empty-args check and before `String itemId = args.getFirst();`:
|
||||||
|
```java
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the `help()` text:
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public String help() {
|
||||||
|
return "use <item> - use an item; or use <item> on <item> to combine them";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=UseCommandComboTest`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java
|
||||||
|
git commit -m "feat(command): route use X on Y to the combination engine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Loader wiring + demo content
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`
|
||||||
|
- Modify: `src/main/resources/world/items.yaml`
|
||||||
|
- Modify: `src/main/resources/world/rooms.yaml`
|
||||||
|
- Create: `src/main/resources/world/combinations.yaml`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`:
|
||||||
|
```java
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationLoadingTest`
|
||||||
|
Expected: FAIL — no `combinations.yaml`/`matches` items yet; `getCombinations()` is empty.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire `WorldLoader`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`:
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `load()`, add the read alongside the other optional reads (after the `endingDtos` line):
|
||||||
|
```java
|
||||||
|
List<CombinationDto> combinationDtos =
|
||||||
|
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `endings` list is built (and after `items` is populated), build the combinations map:
|
||||||
|
```java
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `World` construction to the 8-arg form:
|
||||||
|
```java
|
||||||
|
World world = new World(rooms, items, npcs,
|
||||||
|
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add demo items to `items.yaml`**
|
||||||
|
|
||||||
|
Append to `src/main/resources/world/items.yaml`:
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
- 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
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Place demo items in rooms**
|
||||||
|
|
||||||
|
In `src/main/resources/world/rooms.yaml`:
|
||||||
|
- Change the `library` room's items line from `items: [shovel]` to:
|
||||||
|
```yaml
|
||||||
|
items: [shovel, matches]
|
||||||
|
```
|
||||||
|
- Change the `hallway` room's items line from `items: []` to:
|
||||||
|
```yaml
|
||||||
|
items: [torch]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create `combinations.yaml`**
|
||||||
|
|
||||||
|
`src/main/resources/world/combinations.yaml`:
|
||||||
|
```yaml
|
||||||
|
- 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationLoadingTest`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Full suite (no regressions from new items)**
|
||||||
|
|
||||||
|
Run: `mvn -q clean test`
|
||||||
|
Expected: BUILD SUCCESS, all tests green. (WorldLoaderTest uses its own test fixture base, not these production resources, so it is unaffected.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \
|
||||||
|
src/main/resources/world/items.yaml \
|
||||||
|
src/main/resources/world/rooms.yaml \
|
||||||
|
src/main/resources/world/combinations.yaml \
|
||||||
|
src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java
|
||||||
|
git commit -m "feat(loader): load combinations.yaml; demo matches+torch -> lit_torch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mark the combination mechanic implemented**
|
||||||
|
|
||||||
|
In `docs/enhancement-ideas.md`, under section **5b** (Mechanik-Spine), add a `> ✅`
|
||||||
|
blockquote note after the bullet for mechanic **4. Item-Kombination**, mirroring
|
||||||
|
the existing `> ✅ umgesetzt (Branch ...)` style used for light/darkness etc.:
|
||||||
|
|
||||||
|
> ✅ 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ängig. Kein Parser-Eingriff
|
||||||
|
> (`on`/`with`/`to` sind bereits Filler). Demo: `matches` + `torch` → `lit_torch`
|
||||||
|
> (dauerhafte Lichtquelle).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/enhancement-ideas.md
|
||||||
|
git commit -m "docs: mark use X on Y item combination implemented"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
- [ ] `mvn -q clean test` — all tests pass (162 prior + the new combination suites).
|
||||||
|
- [ ] Manual console smoke (optional): start a game, navigate to the hallway (`take torch`) and library (`take matches`), then `use matches on torch` → "You ... lit torch"; `inventory` shows `lit_torch`, `matches`/`torch` gone.
|
||||||
|
- [ ] `git status` clean.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Spec: Item-Kombination `use X on Y`
|
||||||
|
|
||||||
|
Stand: 2026-06-01. Erste der verbleibenden Mechaniken nach Hauptmenü +
|
||||||
|
Speichern/Laden. Setzt das in `enhancement-ideas.md` #5b (Mechanik-Spine §4)
|
||||||
|
festgelegte Item-Kombinations-Feature um. Baut auf der bestehenden
|
||||||
|
Condition/Effect-Engine, dem datengetriebenen Loader (wie quests/endings) und
|
||||||
|
dem vorhandenen `UseCommand` auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
v1.0 hat bewusst argloses `use X` ohne Ziel gewählt. Ziel jetzt: **`use X on Y`**
|
||||||
|
als datengetriebene **Kombinations-Mechanik** — ein Rezept kann Bedingungen
|
||||||
|
prüfen, Items verbrauchen, ein Item erzeugen, Flags setzen und einen Text
|
||||||
|
ausgeben. Deckt sowohl Transform (`matches + torch → lit_torch`) als auch
|
||||||
|
Anwenden-auf-Ziel (`use key on door → effekt`) mit *einem* Modell ab.
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
||||||
|
- **Allgemeine, datengetriebene Rezepte** (requires / consume / produce / effects
|
||||||
|
/ response / failText) — Superset, nutzt die vorhandene Engine.
|
||||||
|
- **Operanden** lösen aus **Inventar oder aktuellem Raum** auf (`use key on door`,
|
||||||
|
`use matches on torch`).
|
||||||
|
- **Fehlschlag**: kein Rezept → generische Zeile; Rezept vorhanden aber `requires`
|
||||||
|
nicht erfüllt → optionales `failText` (Hinweis), sonst generische Zeile.
|
||||||
|
- **Demo**: ein minimales Showcase-Rezept (`matches + torch → lit_torch`) plus die
|
||||||
|
wenigen dafür nötigen Items, in bestehende Räume gestellt.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `UseCommand`-Routing: 0 Args → "Use what?"; 1 Arg → bestehendes Einzel-`use`;
|
||||||
|
≥2 Args → Kombination der ersten beiden Operanden.
|
||||||
|
- `Combination`-Datenmodell + optionale `combinations.yaml` (geladen wie
|
||||||
|
quests/endings über `readListOptional`).
|
||||||
|
- `CombinationDto` + Factory + `World.combinations` (Map mit kanonischem Paar-Key).
|
||||||
|
- `Combinations`-Engine (`game`) — Auflösung, Lookup, requires/consume/produce/
|
||||||
|
effects/response/failText.
|
||||||
|
- `WorldValidator`-Regeln: `a`/`b`/`consume`/`produce` referenzieren existierende
|
||||||
|
Item-Ids; keine doppelten Paare.
|
||||||
|
- Minimales Demo-Rezept + Items.
|
||||||
|
- Tests: Engine, UseCommand-Routing, Loader, Validator.
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Parser-Änderung — `on`/`with`/`to` sind **bereits** Filler (s. §3); keine neue
|
||||||
|
Grammatik nötig.
|
||||||
|
- Mehrteilige (>2) Rezepte / Kombination aus 3+ Items.
|
||||||
|
- Mehrwort-Item-Namen-Auflösung (Operanden lösen per Item-Id auf, wie heute).
|
||||||
|
- Größerer Content-/Rätsel-Ausbau (eigenes Teilprojekt).
|
||||||
|
|
||||||
|
## 3. Grammatik & Parsing (keine Änderung nötig)
|
||||||
|
|
||||||
|
`CommandParser.FILLERS` enthält bereits `to`, `with`, `on`, `at`, `the`, `a`,
|
||||||
|
`an`. Daher liefern alle drei Eingaben dieselbe Args-Liste:
|
||||||
|
|
||||||
|
```
|
||||||
|
use matches on torch → verb=use, args=[matches, torch]
|
||||||
|
use matches with torch → verb=use, args=[matches, torch]
|
||||||
|
use matches torch → verb=use, args=[matches, torch]
|
||||||
|
```
|
||||||
|
|
||||||
|
`UseCommand` verzweigt nur über die Arg-Anzahl:
|
||||||
|
|
||||||
|
```java
|
||||||
|
if (args.isEmpty()) { io.write("Use what?"); return; }
|
||||||
|
if (args.size() >= 2) { Combinations.tryUse(ctx, args.get(0), args.get(1)); return; }
|
||||||
|
// 1 Arg: bestehende Einzel-use-Logik (Inventar dann Raum, item.use(ctx))
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei ≥2 Args zählen die **ersten beiden** Tokens als Operandenpaar.
|
||||||
|
|
||||||
|
## 4. Datenmodell (model)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Combination(
|
||||||
|
String a, String b, // Operanden-Item-Ids (ungeordnet)
|
||||||
|
List<Condition> requires, // optional; leer = immer erfüllt
|
||||||
|
List<String> consume, // Item-Ids, die entfernt werden
|
||||||
|
String produce, // optional; Item-Id, die ins Inventar kommt (null = keins)
|
||||||
|
List<Effect> effects, // optional; Flag-Mutationen
|
||||||
|
String response, // Erfolgstext
|
||||||
|
String failText // optional; Hinweis bei nicht erfüllten requires
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
/** Kanonischer, reihenfolge-unabhängiger Schlüssel für ein Item-Paar. */
|
||||||
|
public static String key(String x, String y) {
|
||||||
|
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
||||||
|
}
|
||||||
|
public String key() { return key(a, b); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`combinations.yaml` (Demo):
|
||||||
|
```yaml
|
||||||
|
- a: matches
|
||||||
|
b: torch
|
||||||
|
consume: [matches]
|
||||||
|
produce: lit_torch
|
||||||
|
response: |
|
||||||
|
You strike the matches; the torch catches with a low hiss and steady glow.
|
||||||
|
# requires/effects/failText optional, hier nicht nötig
|
||||||
|
```
|
||||||
|
|
||||||
|
`World` erhält `Map<String, Combination> combinations` (Key = `Combination.key()`).
|
||||||
|
Backward-kompatible Konstruktor-Überladung wie bei quests/endings.
|
||||||
|
|
||||||
|
## 5. Kombinations-Engine (game/Combinations.java)
|
||||||
|
|
||||||
|
Statische Utility wie `Conditions`/`Effects`/`Light`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
|
Player p = ctx.getPlayer();
|
||||||
|
// 1. Beide Operanden auflösen (Inventar zuerst, dann aktueller Raum)
|
||||||
|
if (!present(ctx, idX)) { ctx.getIo().write(noSuch(idX)); return; }
|
||||||
|
if (!present(ctx, idY)) { ctx.getIo().write(noSuch(idY)); return; }
|
||||||
|
// 2. Rezept-Lookup über kanonischen Key
|
||||||
|
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
|
||||||
|
if (c == null) { ctx.getIo().write("Nothing happens."); return; }
|
||||||
|
// 3. requires prüfen
|
||||||
|
if (!Conditions.all(c.requires(), ctx)) {
|
||||||
|
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 4. consume → produce → effects → response
|
||||||
|
for (String id : c.consume()) { removeFromAnywhere(ctx, id); }
|
||||||
|
if (c.produce() != null) {
|
||||||
|
Item produced = ctx.getWorld().getItems().get(c.produce());
|
||||||
|
p.addItem(produced);
|
||||||
|
}
|
||||||
|
Effects.applyAll(c.effects(), ctx);
|
||||||
|
ctx.getIo().print(/* response als StyledText */);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hilfsmethoden:
|
||||||
|
- `present(ctx, id)` → Inventar **oder** aktueller Raum enthält `id`.
|
||||||
|
- `removeFromAnywhere(ctx, id)` → `player.removeItem(id)`, sonst
|
||||||
|
`currentRoom.removeItem(id)`.
|
||||||
|
- `noSuch(id)` → "There is no '<id>' here or in your inventory." (wie UseCommand).
|
||||||
|
- Erzeugtes Item ist in `items.yaml` definiert, aber in **keinem** Raum platziert
|
||||||
|
(existiert in `world.getItems()`, „nirgends"), bis es gefertigt wird → dann ins
|
||||||
|
Inventar. Save/Load erfasst Item-Platzierung + Switch-Zustände bereits korrekt;
|
||||||
|
ungefertigte Items bleiben unplatziert.
|
||||||
|
|
||||||
|
## 6. Loader-Anbindung
|
||||||
|
|
||||||
|
- `CombinationDto` (Jackson-Record): `a, b, requires, consume, produce, effects,
|
||||||
|
response, failText`. `requires`/`effects` via `ConditionDto.toModelList` /
|
||||||
|
`EffectDto.toModelList` (vorhanden).
|
||||||
|
- `WorldLoader`: `readListOptional(basePath + "/combinations.yaml", CombinationDto.class)`.
|
||||||
|
- `CombinationFactory`: DTO → `Combination`; sammelt in `Map<String, Combination>`
|
||||||
|
per `Combination.key()`. Doppelter Key → `WorldLoadException`.
|
||||||
|
- `World.combinations` + Getter; backward-kompatible Konstruktoren.
|
||||||
|
|
||||||
|
## 7. Validierung (WorldValidator)
|
||||||
|
|
||||||
|
- `a`, `b`, jede `consume`-Id und (falls gesetzt) `produce` müssen in der
|
||||||
|
Item-Registry existieren → sonst `WorldLoadException` mit klarer Meldung.
|
||||||
|
- Doppelte Paar-Keys → Fehler (im Factory bereits abgefangen; Validator
|
||||||
|
dokumentiert die Regel).
|
||||||
|
- `requires`/`effects` nutzen die bestehende Condition/Effect-Validierung, falls
|
||||||
|
vorhanden; sonst rein strukturell.
|
||||||
|
|
||||||
|
## 8. Demo-Content (minimal)
|
||||||
|
|
||||||
|
- `items.yaml`: `matches` (plain), `torch` (plain, `light: false`), `lit_torch`
|
||||||
|
(**plain mit `light: true`**). `Light.isActiveLight` wertet ein Nicht-Switchable
|
||||||
|
mit `light: true` als **immer aktive** Lichtquelle (nur SwitchableItems müssen
|
||||||
|
„on" sein) — also ist `lit_torch` eine dauerhaft brennende Lichtquelle, ohne
|
||||||
|
Toggle. `lit_torch` wird **nicht** in einem Raum platziert (entsteht nur durchs
|
||||||
|
Rezept).
|
||||||
|
- `rooms.yaml`: `matches` und `torch` in erreichbare Räume legen.
|
||||||
|
- `combinations.yaml`: das eine Rezept aus §4.
|
||||||
|
- Spielbar nachweisbar: `take matches`, `take torch`, `use matches on torch` →
|
||||||
|
`lit_torch` im Inventar (Lichtquelle), `matches` verbraucht.
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
- **`CombinationsTest`**: ungeordneter Match (`X on Y` == `Y on X`); `requires`
|
||||||
|
erfüllt/nicht erfüllt (failText vs. generisch); consume aus Inventar **und** aus
|
||||||
|
Raum; produce landet im Inventar; effects gesetzt; kein Rezept → "Nothing
|
||||||
|
happens."; fehlender Operand → noSuch-Meldung.
|
||||||
|
- **`UseCommand`-Routing**: 0 Args → "Use what?"; 1 Arg → `item.use` (bestehend);
|
||||||
|
2 Args → Combinations-Pfad (über Fake-/TestIO + kleine Welt).
|
||||||
|
- **Loader**: `combinations.yaml` → `World.combinations` korrekt; Key-Kanonisierung;
|
||||||
|
doppeltes Paar → Exception.
|
||||||
|
- **Validator**: unbekannte `a`/`b`/`consume`/`produce`-Id → `WorldLoadException`.
|
||||||
|
- Konvention: keine GUI-/YAML-abhängigen Domain-Tests; Engine-Tests bauen Welt
|
||||||
|
inline wie `QuestEngineTest`.
|
||||||
|
|
||||||
|
## 10. Architektur-Werte / Risiken
|
||||||
|
|
||||||
|
- **Datengetrieben**: Rezepte in YAML, kein Hardcoding; gleiche DTO/Domain-Trennung
|
||||||
|
wie quests/endings.
|
||||||
|
- **Geringe Kopplung**: neue `Combinations`-Utility neben `Conditions`/`Effects`;
|
||||||
|
`UseCommand` bekommt nur eine Verzweigung. Kein Parser-Eingriff.
|
||||||
|
- **Reihenfolge-Unabhängigkeit** über kanonischen Key vermeidet doppelte Rezepte.
|
||||||
|
- **Erzeugte Items** „aus dem Nichts": müssen in `items.yaml` definiert, aber
|
||||||
|
unplatziert sein — Validator stellt Existenz sicher; Save/Load erfasst sie nach
|
||||||
|
dem Fertigen automatisch.
|
||||||
|
- Risiko: `produce` eines bereits irgendwo platzierten Items würde dieselbe Instanz
|
||||||
|
ins Inventar verschieben (Items sind Singletons). Demo vermeidet das (unplatziert).
|
||||||
|
Validator/Doku weist darauf hin.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -8,9 +9,10 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the item-specific {@link Item#use(GameContext)} action. The
|
* With one argument, invokes the item-specific {@link Item#use(GameContext)}
|
||||||
* item must either be in the player's inventory or in the current room.
|
* action; the item must be in the player's inventory or the current room.
|
||||||
* Usage: {@code use <item-id>}.
|
* 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 {
|
public class UseCommand implements Command {
|
||||||
|
|
||||||
@@ -20,6 +22,10 @@ public class UseCommand implements Command {
|
|||||||
ctx.getIo().write("Use what?");
|
ctx.getIo().write("Use what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
String itemId = args.getFirst();
|
String itemId = args.getFirst();
|
||||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty()) {
|
||||||
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.type.CollectionType;
|
|||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.EndingDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
@@ -75,6 +77,8 @@ public class WorldLoader {
|
|||||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||||
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
||||||
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.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);
|
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||||
|
|
||||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||||
@@ -104,6 +108,16 @@ public class WorldLoader {
|
|||||||
endings.add(EndingFactory.fromDto(dto));
|
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);
|
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||||
resolver.resolveRooms(roomDtos);
|
resolver.resolveRooms(roomDtos);
|
||||||
resolver.resolveNpcs(npcDtos);
|
resolver.resolveNpcs(npcDtos);
|
||||||
@@ -115,7 +129,7 @@ public class WorldLoader {
|
|||||||
Player player = new Player(start, gold);
|
Player player = new Player(start, gold);
|
||||||
|
|
||||||
World world = new World(rooms, items, npcs,
|
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",
|
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||||
return new LoadResult(world, player);
|
return new LoadResult(world, player);
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,15 +37,24 @@ public class World {
|
|||||||
/** Ordered list of endings (first matching one wins). */
|
/** Ordered list of endings (first matching one wins). */
|
||||||
private final List<Ending> endings;
|
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,
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
String title, String welcomeMessage) {
|
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,
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
String title, String welcomeMessage, Map<String, Quest> quests) {
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -47,3 +47,19 @@
|
|||||||
You ease the door shut again.
|
You ease the door shut again.
|
||||||
effects:
|
effects:
|
||||||
- { setFlag: fled }
|
- { 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
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
exits:
|
exits:
|
||||||
south: kitchen
|
south: kitchen
|
||||||
west: library
|
west: library
|
||||||
items: []
|
items: [torch]
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|
||||||
- id: library
|
- id: library
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
An old chest stands in the corner.
|
An old chest stands in the corner.
|
||||||
exits:
|
exits:
|
||||||
east: hallway
|
east: hallway
|
||||||
items: [shovel]
|
items: [shovel, matches]
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|
||||||
- id: cellar
|
- id: cellar
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ class WorldLoaderTest {
|
|||||||
|
|
||||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
||||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
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.world().getNpcs().keySet()).containsExactly("old_man");
|
||||||
|
|
||||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -17,3 +17,19 @@
|
|||||||
id: key
|
id: key
|
||||||
name: Key
|
name: Key
|
||||||
description: A 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
|
||||||
|
|||||||
Reference in New Issue
Block a user