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>
7.9 KiB
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. 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 miteffects. - 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)
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)
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 ausworld.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
# 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. JeweilstoModel(). - Erweiterte DTOs:
RoomDto(+exitLocks, +descriptionStates),NpcDto(+dialogue),ReactionDto(+requires, +effects),ItemDto(+effects). ConditionDto.toModel(): genau ein Feld gesetzt → entsprechendeCondition, sonstWorldLoadException. AnalogEffectDto.- Verdrahtung:
ReferenceResolver.resolveRoomsbautexitLocks(Direction viaDirection.fromString) unddescriptionStates;resolveNpcsbautdialogueund erweitert dieNpcReactionumrequires/effects;ItemFactorysetztSwitchableItem.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_ITEMunbekannt 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, sonstgreeting.GiveCommand: Reaktion mitrequiresblockt/erlaubt;effectssetzen 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: schlichtio.writeoder gestylt (StyledText) – zunächstwrite.- Ob
ExitLock/DescriptionState/DialogueLineals records inmodelodermodel.stateliegen – im Plan festgelegt (model).