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>
186 lines
7.9 KiB
Markdown
186 lines
7.9 KiB
Markdown
# 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`).
|