docs: spec for quest foundation (world-state + conditions/effects)

Approach A: one typed condition/effect vocabulary (GameState + Conditions +
Effects) reused by five integration points (locked exits, state-dependent
descriptions, conditional NPC dialogue, reactions with requires/effects,
switch effects). All new YAML fields optional. Light/dark, item-combination,
quest objects, endings deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:39:59 +02:00
parent fdc4915431
commit f79382c43a

View File

@@ -0,0 +1,185 @@
# Spec: Quest-Fundament World-State & Bedingungen/Effekte (Teilprojekt 3.1)
Stand: 2026-05-31. Erstes Stück des Quest-Systems (Teilprojekt 3) aus
[enhancement-ideas.md](../../enhancement-ideas.md). Liefert die Zustands- und
Bedingungs-/Effekt-Engine, auf der Quests (#3.2) und Enden (#3.3) aufbauen.
## 1. Kontext & Ziel
Das NPC-System ist heute statisch: fester `greeting`, Reaktionen
(`consumes`/`gives`/`response`) ohne Bedingungen, kein Weltzustand. Ziel:
**gated progression** datengetrieben ermöglichen verschlossene Türen,
zustandsabhängige Beschreibungen, reaktive NPCs, Schalter, die Weltzustand setzen
über **eine** kleine, wiederverwendbare Bedingungs-/Effekt-Sprache (Ansatz A).
Scope **core-only** (bestätigt): Licht/Dunkelheit und Item-Kombination werden
bewusst auf spätere kleine Folge-Specs verschoben.
## 2. Scope
**In Scope:**
- **`GameState`** Flag-Speicher (benannte Booleans).
- **Condition/Effect-Modell** + Auswerter (`Conditions`) + Anwender (`Effects`).
- Fünf Integrationspunkte: verschlossene Exits, zustandsabhängige
Raumbeschreibungen, bedingte NPC-Dialoge, Reaktionen mit `requires`/`effects`,
Schalter mit `effects`.
- Loader/DTO/Factory/Resolver-Erweiterungen, alle neuen YAML-Felder **optional**
(Rückwärtskompatibilität).
**Out of Scope (eigene spätere Specs):**
- Licht & Dunkelheit; Item-Kombination (`use X on Y`).
- Quest-Objekte, Quest-Log, GUI-Quest-Box (Teilprojekt #3.2).
- Win-Condition / mehrere Enden (#3.3).
- `startFlags`-Seeding (YAGNI Flags starten leer = alle false, was der
Ausgangslage „Strom aus, Keller geflutet" entspricht).
## 3. Kern-Engine (Ansatz A)
### 3.1 GameState
`GameState` (Package `game`) hält die gesetzten Flags als `Set<String>`
(vorhanden = true). API: `isSet(name)`, `set(name)`, `clear(name)`, `all()`.
`GameContext` bekommt ein **selbst-initialisiertes** Feld
`private final GameState state = new GameState();` (per `@Getter` lesbar). Da das
Feld initialisiert ist, bleibt der `@RequiredArgsConstructor` (world, player, io)
**unverändert** keine Anpassung der bestehenden Konstruktionsstellen nötig.
### 3.2 Condition (Daten in `model`, Auswerter in `game`)
```java
public record Condition(Type type, String arg) {
public enum Type { FLAG, NOT_FLAG, HAS_ITEM }
}
```
`Conditions.all(List<Condition>, GameContext)` → true, wenn alle gelten (leere
Liste = true):
- `FLAG``state.isSet(arg)`
- `NOT_FLAG``!state.isSet(arg)`
- `HAS_ITEM``player.hasItem(arg)`
### 3.3 Effect (Daten in `model`, Anwender in `game`)
```java
public record Effect(Type type, String arg) {
public enum Type { SET_FLAG, CLEAR_FLAG, GIVE_ITEM, REMOVE_ITEM, SAY }
}
```
`Effects.applyAll(List<Effect>, GameContext)` wendet jeden an:
- `SET_FLAG`/`CLEAR_FLAG``state.set/clear(arg)`
- `GIVE_ITEM` → Item aus `world.getItems().get(arg)` ins Inventar (unbekannt →
Warnung geloggt, übersprungen)
- `REMOVE_ITEM``player.removeItem(arg)`
- `SAY``io.write(arg)`
## 4. Integrationspunkte
Jeder trägt nur `requires` (Conditions) und/oder `effects` und ruft die Helfer.
| # | Träger (model) | YAML-Feld | Konsument | Verhalten |
|---|---|---|---|---|
| 1 | `Room.exitLocks: EnumMap<Direction, ExitLock>` | `exitLocks` | `GoCommand` | Vor Bewegung: gilt `requires` nicht → `blocked` ausgeben, nicht bewegen |
| 2 | `Room.descriptionStates: List<DescriptionState>` | `descriptionStates` | `LookCommand` | Erste passende Variante als Beschreibung, sonst `description` |
| 3 | `Npc.dialogue: List<DialogueLine>` | `dialogue` | `TalkCommand` | Erste passende Zeile, sonst `greeting` |
| 4 | `NpcReaction.requires/effects` | `requires`, `effects` | `GiveCommand` | `requires` prüfen; bei Erfolg consume/give wie bisher + `effects` |
| 5 | `SwitchableItem.effects: List<Effect>` | `effects` | `use` | Beim Schalten **auf „on"**: `effects` anwenden |
Hilfstypen (records in `model`): `ExitLock(List<Condition> requires, String blocked)`,
`DescriptionState(List<Condition> when, String text)`,
`DialogueLine(List<Condition> when, String text)`.
### YAML-Beispiele
```yaml
# rooms.yaml
exits: { east: cellar }
exitLocks:
- direction: east
requires: [{ flag: power_on }]
blocked: "The cellar door won't budge without power."
descriptionStates:
- when: [{ flag: power_on }]
text: "The cellar, now lit, reveals a workbench."
# npcs.yaml
dialogue:
- when: [{ flag: power_on }]
text: '"You did it — the lights are back!"'
reactions:
- onReceive: fuse
requires: [{ flag: cellar_drained }]
response: "That'll hold now."
effects:
- { removeItem: fuse }
- { setFlag: fuse_installed }
# items.yaml
- id: breaker
type: switchable
name: Breaker
onText: "You flip the breaker. The manor hums to life."
offText: "You switch the breaker off."
state: false
effects:
- { setFlag: power_on }
```
## 5. Loading / DTO / Mapping
- Neue DTOs (`loader.dto`): `ConditionDto(flag, notFlag, hasItem)`,
`EffectDto(setFlag, clearFlag, giveItem, removeItem, say)`, `ExitLockDto`,
`DescriptionStateDto`, `DialogueLineDto`. Jeweils `toModel()`.
- Erweiterte DTOs: `RoomDto` (+`exitLocks`, +`descriptionStates`), `NpcDto`
(+`dialogue`), `ReactionDto` (+`requires`, +`effects`), `ItemDto` (+`effects`).
- `ConditionDto.toModel()`: genau ein Feld gesetzt → entsprechende `Condition`,
sonst `WorldLoadException`. Analog `EffectDto`.
- Verdrahtung: `ReferenceResolver.resolveRooms` baut `exitLocks` (Direction via
`Direction.fromString`) und `descriptionStates`; `resolveNpcs` baut `dialogue`
und erweitert die `NpcReaction` um `requires`/`effects`; `ItemFactory` setzt
`SwitchableItem.effects`.
- **Item-Referenzen in Conditions/Effects** (`hasItem`/`giveItem`/`removeItem`)
werden **nicht** beim Laden aufgelöst, sondern zur Laufzeit (lazy) kein
Eingriff in die strikte Resolver-Validierung; `GIVE_ITEM` unbekannt ist
laufzeit-tolerant (Warnung).
## 6. Fehlerbehandlung
| Fall | Verhalten |
|---|---|
| Condition/Effect-DTO ohne gesetztes Feld | `WorldLoadException` beim Laden |
| `exitLocks` mit unbekannter Richtung | `WorldLoadException` (`Direction.fromString`) |
| `GIVE_ITEM` mit unbekannter Item-ID | Warnung geloggt, Effekt übersprungen |
| Neue Felder fehlen (alte YAMLs) | null → als leere Liste behandelt, lädt normal |
| Schalter `off``on` mehrfach | `effects` werden bei jedem Übergang auf „on" angewendet (idempotent bei `setFlag`) |
## 7. Rückwärtskompatibilität
Alle neuen Felder sind optional (nullable). Bestehende `rooms.yaml`, `npcs.yaml`,
`items.yaml` und alle Test-Fixtures laden unverändert; die 90 bestehenden Tests
bleiben grün. `NpcReaction` behält `consumes`/`gives`/`response`.
## 8. Testing
- **Unit:** `GameState` (set/clear/isSet); `Conditions.all` (FLAG/NOT_FLAG/HAS_ITEM,
AND, leer=true); `Effects.applyAll` (alle fünf Typen, unbekanntes Item tolerant);
`ConditionDto.toModel`/`EffectDto.toModel` (inkl. Fehlerfall).
- **Integration (in-Code-Welt wie bestehende Command-Tests):**
- `GoCommand`: gesperrter Exit blockt ohne Flag, passiert mit Flag.
- `LookCommand`: Beschreibung wechselt mit Flag.
- `TalkCommand`: Dialogzeile wechselt mit Flag, sonst `greeting`.
- `GiveCommand`: Reaktion mit `requires` blockt/erlaubt; `effects` setzen Flag.
- `SwitchableItem.use`: setzt Flag beim Einschalten.
- **Loader:** ein Round-Trip-Test, der ein YAML mit allen neuen Feldern lädt;
ein Test, dass alte YAMLs ohne neue Felder weiterhin laden.
## 9. Demonstration (am Ende, minimal)
Eine kleine, sichere Erweiterung der echten Welt-YAMLs, um die Kette
end-to-end spielbar zu zeigen (z. B. Schalter setzt ein Flag, das eine Tür
öffnet und eine Beschreibung ändert) ohne bestehende Tests zu berühren.
## 10. Offene Detailfragen (in Implementierung entscheidbar)
- `SAY`-Effekt: schlicht `io.write` oder gestylt (`StyledText`) zunächst `write`.
- Ob `ExitLock`/`DescriptionState`/`DialogueLine` als records in `model` oder
`model.state` liegen im Plan festgelegt (`model`).