docs: spec for use X on Y item combinations
Data-driven combination recipes (requires/consume/produce/effects/response/ failText), operands from inventory or current room, order-independent lookup, generic-or-failText feedback. No parser change (on/with already fillers). Minimal matches+torch -> lit_torch demo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
Reference in New Issue
Block a user