semesterprojekt: implement full text adventure (phases 1-7)

Walking skeleton through Swing GUI: YAML-driven world (4 rooms,
4 items, 1 NPC), HashMap command dispatch with parser, three-tier
item hierarchy (readable / switchable / plain), and end-to-end
NPC give/receive flow. 67 tests green.
This commit is contained in:
2026-05-25 21:37:59 +02:00
parent 9b6528d800
commit 83643a192f
85 changed files with 4453 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
# Semesterprojekt Textadventure
Design- und Architekturdokumentation. Dient als Spec während der Implementierung.
## Inhalt
| Datei | Inhalt |
|---|---|
| [architecture.md](architecture.md) | Package-Struktur, Schichten, DTO-vs-Domain-Trennung |
| [conventions.md](conventions.md) | Sprache, ID-Format, Naming, Lombok-Cheatsheet |
| [data-structures.md](data-structures.md) | Alle gewählten Collection-Typen mit Begründung |
| [item-model.md](item-model.md) | Item-Hierarchie (abstract + 3 Subtypen), Lombok-Inheritance |
| [yaml-schemas.md](yaml-schemas.md) | Schemas für `items.yaml`, `rooms.yaml`, `npcs.yaml`, `game.yaml` |
| [loading-flow.md](loading-flow.md) | Lade-Reihenfolge, Referenz-Auflösung, Validierung |
| [commands.md](commands.md) | Befehlsparser, Command-Pattern, Befehlsliste |
| [npcs.md](npcs.md) | NPC-Modell, Talk- und Give-Interaktion |
| [implementation-status.md](implementation-status.md) | Aktueller Stand, Phasen-Checkliste, festgelegte Entscheidungen |
## Pflicht vs. Optional (laut Aufgabenstellung)
- **Pflicht:** ≥4 Räume mit Navigation, ≥3 Gegenstände mit Inventar
- **Optional/Bonus:** NPCs, Swing-GUI
Beide optionalen Teile sind hier eingeplant.
## Technologie-Stack
- Java 25
- Jackson (YAML) für Daten-Loading
- Lombok für Boilerplate-Reduktion
- JUnit 5 + AssertJ + Mockito für Tests
- Logback + SLF4J für Logging
- Swing für GUI (Bonus)

View File

@@ -0,0 +1,93 @@
# Architektur
## Schichten
```mermaid
flowchart TD
IO["io (Konsole / GUI)<br/>Interaktion mit Spieler"]
GAME["game (Engine/Loop)<br/>Spielfluss, hält World + Player"]
CMD["command (Commands)<br/>go, take, use, talk, ..."]
MODEL["model (Domain)<br/>Room, Item, Player, Npc, World"]
LOADER["loader (YAML + DTO)<br/>liest Ressourcen, validiert"]
IO -- "liest/schreibt via GameIO" --> GAME
GAME -- "dispatcht via CommandRegistry" --> CMD
CMD -- "mutiert" --> MODEL
LOADER -- "baut auf aus DTOs" --> MODEL
```
## Package-Struktur
```mermaid
flowchart LR
root["thb.jeanluc.adventure"]
root --> app["App.java<br/>AppGui.java"]
root --> model
root --> loader
root --> command
root --> game
root --> io
model --> model_files["World, Room,<br/>Player, Npc, Direction"]
model --> item
item --> item_files["Item (abstract)<br/>ReadableItem<br/>SwitchableItem<br/>PlainItem"]
loader --> loader_files["WorldLoader<br/>ReferenceResolver<br/>WorldValidator"]
loader --> dto
dto --> dto_files["RoomDto, ItemDto,<br/>NpcDto, GameDto"]
command --> cmd_core["Command (Interface)<br/>CommandRegistry<br/>CommandParser"]
command --> impl
impl --> impl_files["GoCommand, TakeCommand,<br/>DropCommand, UseCommand,<br/>InventoryCommand, LookCommand,<br/>TalkCommand, GiveCommand,<br/>HelpCommand, QuitCommand"]
game --> game_files["Game (Loop)<br/>GameContext"]
io --> io_files["GameIO (Interface)<br/>ConsoleIO<br/>SwingIO"]
```
## DTO vs. Domain-Trennung
**Kernprinzip:** YAML wird in Records deserialisiert (DTOs), erst danach werden String-IDs zu Objekt-Referenzen aufgelöst.
| | DTO | Domain |
|---|---|---|
| Typ | `record` | Lombok-Klasse |
| Mutabilität | immutable | mutable wo nötig |
| Felder | nur Daten + String-IDs | Objekt-Referenzen, EnumMap, etc. |
| Zweck | Jackson-Mapping | Spielablauf |
| Tests | Loader-Tests | Domain-Tests, brauchen kein YAML |
**Warum getrennt?**
- Domain-Klassen müssen nicht mit Jackson-Annotations verschmutzt werden
- `Room.exits` ist `EnumMap<Direction, Room>` (typisicher, schnell) statt `Map<String, String>` (was zum YAML passt)
- Validierung passiert beim Übergang DTO→Domain (siehe [loading-flow.md](loading-flow.md))
- Domain-Tests können Objekte direkt im Code bauen, ohne YAML-Fixtures
## Game-Loop (vereinfacht)
```java
while (!game.isOver()) {
String input = io.read();
ParsedCommand parsed = parser.parse(input);
Command cmd = registry.get(parsed.verb());
if (cmd == null) {
io.write("Unbekannter Befehl.");
} else {
cmd.execute(context, parsed.args());
}
}
```
## IO-Abstraktion
Konsole und GUI teilen sich `GameIO`:
```java
public interface GameIO {
String read(); // blockierende Leseoperation
void write(String text);
}
```
Damit ist der Game-Loop **identisch** für beide Modi. `SwingIO` blockiert intern mit einer `BlockingQueue<String>`, die vom JTextField-ActionListener gefüllt wird.

View File

@@ -0,0 +1,127 @@
# Befehle
Command-Pattern mit Registry — passt zum Aufgaben-Tipp „switch, if-else, HashMap" und macht die HashMap-Variante explizit.
## Interface
```java
public interface Command {
/** Wird vom Parser nach Tokenisierung aufgerufen. */
void execute(GameContext ctx, List<String> args);
/** Short help text for the 'help' command. */
String help();
}
```
## Registry
```java
public class CommandRegistry {
private final Map<String, Command> commands = new HashMap<>();
public void register(Command cmd, String... names) {
for (String n : names) {
commands.put(n.toLowerCase(), cmd);
}
}
public Optional<Command> find(String verb) {
return Optional.ofNullable(commands.get(verb.toLowerCase()));
}
}
```
**Aliase per Mehrfach-Registrierung:**
```java
registry.register(new GoCommand(), "go", "move");
registry.register(new TakeCommand(), "take", "pick");
```
## Parser
Einfacher tokenisierender Parser. Erst-Token = Verb, Rest = Argumente.
Spezialfall: Präpositionen / Artikel wegfiltern, damit `go to north` und `go north` beide funktionieren.
```java
public record ParsedCommand(String verb, List<String> args) {}
public class CommandParser {
private static final Set<String> FILLERS = Set.of("to", "with", "at", "the", "a", "an");
public ParsedCommand parse(String input) {
String[] tokens = input.trim().toLowerCase().split("\\s+");
if (tokens.length == 0 || tokens[0].isEmpty()) {
return new ParsedCommand("", List.of());
}
String verb = tokens[0];
List<String> args = Arrays.stream(tokens, 1, tokens.length)
.filter(t -> !FILLERS.contains(t))
.toList();
return new ParsedCommand(verb, args);
}
}
```
## Befehlsliste (Pflicht + Optional)
| Verb | Aliase | Wirkung | Quelle |
|---|---|---|---|
| `go <direction>` | `move`, `walk` | Spieler wechselt Raum | Pflicht |
| `take <item>` | `pick`, `get` | Item aus Raum ins Inventar | Pflicht |
| `drop <item>` | `put` | Item aus Inventar in Raum | sinnvoll |
| `use <item>` | — | Item-spezifische Aktion | Pflicht |
| `read <item>` | — | Spezialfall von use für `readable` | Pflicht |
| `inventory` | `inv`, `i` | Inventar anzeigen | sinnvoll |
| `look` | `l` | Raumbeschreibung wiederholen | sinnvoll |
| `examine <item>` | `x` | Item-Beschreibung anzeigen | sinnvoll |
| `talk <npc>` | `speak` | NPC-Greeting ausgeben | NPC-Bonus |
| `give <item> <npc>` | — | Item übergeben, Reaktion auslösen | NPC-Bonus |
| `help` | `?` | Befehlsübersicht | sinnvoll |
| `quit` | `exit` | Spiel beenden | sinnvoll |
## Behandlung unbekannter Befehle
```java
parser.parse(input);
registry.find(parsed.verb())
.ifPresentOrElse(
cmd -> cmd.execute(ctx, parsed.args()),
() -> ctx.io().write("I don't understand '" + parsed.verb() + "'. Type 'help'.")
);
```
## GameContext
Wird allen Commands gereicht, kapselt was sie ändern dürfen:
```java
public class GameContext {
private final World world;
private final Player player;
private final GameIO io;
// Lombok @Getter, kein Setter
}
```
So vermeidest du, dass jeder Command 5 Konstruktor-Parameter braucht.
## Tests
Pro Command ein Testfall, der `GameContext` mit Mockito mockt (oder als Fake-IO baut):
```java
@Test
void goCommand_movesPlayerToConnectedRoom() {
Room kitchen = ...; Room hallway = ...;
kitchen.getExits().put(Direction.NORTH, hallway);
Player p = new Player(kitchen, 0);
GameContext ctx = new GameContext(world, p, new TestIO());
new GoCommand().execute(ctx, List.of("north"));
assertThat(p.getCurrentRoom()).isEqualTo(hallway);
}
```

View File

@@ -0,0 +1,88 @@
# Konventionen
Sprache, IDs, Package-Struktur, Naming.
## Sprache
- **Code:** Englisch — Klassen, Methoden, Felder, Enums, Package-Namen, Javadoc.
- **YAML:** Englisch — Keys, IDs, alle Texte (descriptions, dialogue, readText, etc.).
- **Doku-Prose** (`docs/*.md`): Deutsch — Diskussion mit dem Entwickler, keine Spec für Korrektoren.
## IDs
Slugs, keine UUIDs. Begründung in [item-model.md](item-model.md) und [yaml-schemas.md](yaml-schemas.md).
| Regel | Beispiel | Gegenbeispiel |
|---|---|---|
| nur kleinbuchstaben | `kitchen`, `letter` | `Kitchen`, `LETTER` |
| ASCII, keine Umlaute | `cellar`, `kueche` schlecht | `keller` gut, `küche` schlecht |
| Mehrwortig: snake_case | `oil_lamp`, `old_man` | `oilLamp`, `old-man`, `oil lamp` |
| keine führende Ziffer | `key1` | `1key` |
| nur `[a-z0-9_]` | `key2` | `key#2`, `key@2` |
Regex zur Validierung im `WorldValidator`:
```
^[a-z][a-z0-9_]*$
```
Geltungsbereich: id ist eindeutig **innerhalb seiner Entitätsart**. Ein Item und ein NPC dürfen `lamp` heißen (nicht empfohlen, aber technisch erlaubt).
## Package-Struktur
```
thb.jeanluc.adventure
├── model
│ ├── Direction
│ ├── Room
│ ├── Player
│ ├── Npc
│ └── item <- eigener Subpackage wegen Hierarchie
│ ├── Item (abstract)
│ ├── ReadableItem
│ ├── SwitchableItem
│ └── PlainItem
├── io
├── command
├── game
└── loader
└── dto
```
**Wann ein Subpackage:** wenn eine Klassenfamilie ≥ 3 Klassen umfasst und eine eigene Abstraktion bildet (`item`, evtl. später `command.impl`). Sonst flach lassen.
## Naming
Java-Standard:
- Klassen: `UpperCamelCase`
- Methoden, Felder, Parameter: `lowerCamelCase`
- Konstanten: `UPPER_SNAKE_CASE`
- Packages: `lowercase`, ohne Underscores wenn möglich
YAML:
- Keys: `lowerCamelCase` (`startRoom`, `readText`, `initialState`) — passt zu Jackson-Default-Mapping auf Java-Felder
- IDs: siehe oben
## Javadoc
Pflicht laut Aufgabenstellung **an Klassen, Methoden, und Instanzvariablen**. Auch bei Lombok-Feldern wenn sie `@Getter`-exposed sind.
Keep it sachlich — was tut die Klasse/Methode, nicht wie. Keine Geschichten erzählen.
## Tests
- Unit-Tests pro Domain-Klasse: `RoomTest`, `PlayerTest`, `ItemTest` etc.
- Integration-Tests für Loader: `WorldLoaderTest` mit `src/test/resources/world/`-Fixtures
- Naming: `methodName_condition_expectedResult` — z.B. `addExit_withNewDirection_storesNeighbour`
## Lombok-Cheatsheet
| Annotation | Wozu |
|---|---|
| `@Getter` | Getter für alle Felder |
| `@RequiredArgsConstructor` | Konstruktor nur für finals ohne Initializer |
| `@SuperBuilder` | Builder über Vererbung (statt `@Builder`) |
| `@Slf4j` | `log`-Field für SLF4J |
| `@ToString` | Nur wenn aussagekräftig — sonst weglassen |
**Vermeiden:** `@Data` (zu viel auf einmal, generiert `equals/hashCode` was bei Room/Player unerwünscht ist).

View File

@@ -0,0 +1,73 @@
# Datenstrukturen
Bewusste Wahl jeder Collection — der Dozent bewertet das laut Aufgabenstellung explizit.
## Übersicht
| Verwendung | Struktur | Komplexität Lookup |
|---|---|---|
| Ausgänge eines Raums | `EnumMap<Direction, Room>` | O(1) |
| Alle Räume der Welt | `HashMap<String, Room>` | O(1) |
| Item-Registry (global) | `HashMap<String, Item>` | O(1) |
| NPC-Registry (global) | `HashMap<String, Npc>` | O(1) |
| Items in einem Raum | `LinkedHashMap<String, Item>` | O(1) |
| NPCs in einem Raum | `LinkedHashMap<String, Npc>` | O(1) |
| Spieler-Inventar | `LinkedHashMap<String, Item>` | O(1) |
| Befehlsregistry | `HashMap<String, Command>` | O(1) |
| NPC-Reaktionen | `HashMap<String, NpcReaction>` | O(1) |
| Eingabehistorie (optional) | `ArrayDeque<String>` | O(1) Front/Back |
## Begründungen im Detail
### `EnumMap<Direction, Room>` für Raum-Ausgänge
- **Direction** ist Enum (`NORTH`, `SOUTH`, `EAST`, `WEST`, evtl. `UP`, `DOWN`)
- `EnumMap` ist array-backed, kein Hashing nötig → schneller und kompakter als `HashMap`
- Iteration in Enum-Deklarationsreihenfolge (stabil)
### `HashMap<String, Room>` für die Welt
- Lookup über `id` beim Auflösen von Exits
- Reihenfolge irrelevant (keine Anzeige der gesamten Welt)
- Standardwahl wenn nur Lookup gebraucht wird
### `LinkedHashMap<String, Item>` für Inventar & Raum-Items
Zwei Anforderungen gleichzeitig:
1. **O(1) Lookup** beim `take letter` / `read letter`
2. **Stabile Anzeigereihenfolge** beim `inventory`
Eine plain `HashMap` würde Punkt 2 verletzen (Items springen scheinbar zufällig zwischen Ausgaben). Eine `ArrayList<Item>` würde Punkt 1 auf O(n) drücken und Duplikat-Prüfung verlangen.
Entscheidung "keine Stapel" (1 Item pro id) macht das Map-basierte Modell sauber.
### `HashMap<String, Command>` für Befehle
- O(1)-Dispatch
- Aliase durch Mehrfach-Registrierung (`put("go", goCmd); put("move", goCmd);`)
- Entspricht dem expliziten Tipp aus der Aufgabenstellung
- Vermeidet wachsendes `switch`-Statement
### `ArrayDeque<String>` für Historie (optional)
- Falls Up-Arrow in der GUI gewünscht oder Befehlsverlauf
- `ArrayDeque` ist `LinkedList` praktisch immer überlegen (bessere Cache-Lokalität, weniger Overhead)
- Beidseitige O(1)-Operationen
## Bewusst NICHT gewählt
| Struktur | Warum nicht |
|---|---|
| `ArrayList<Item>` für Inventar | O(n)-Lookup, Duplikat-Handling nötig |
| `HashMap<String, Item>` für Inventar | Anzeige-Reihenfolge instabil |
| `TreeMap` irgendwo | Keine sortierte Iteration nötig, O(log n) ohne Nutzen |
| `LinkedList` | `ArrayDeque` ist fast immer besser |
| `Vector` / `Hashtable` | Legacy, synchronisiert (nicht gebraucht), langsamer |
| `Map<String, String>` für Exits in Domain | Direction sollte Enum sein, nicht String |
## Threading
Single-threaded: Game-Loop liest, dispatcht, schreibt — keine parallelen Mutationen.
**Ausnahme:** Bei Swing-GUI läuft Input über den Event-Dispatch-Thread, der Game-Loop in einem Worker-Thread. Hier kommt `BlockingQueue<String>` (`ArrayBlockingQueue` reicht) als Brücke ins Spiel — siehe [architecture.md](architecture.md).

View File

@@ -0,0 +1,135 @@
# Implementierungsstand & Reihenfolge
Stand: alle Phasen 17 implementiert, 67 Tests grün, End-to-End-Smoke-Test des Walking-Skeletons + YAML-Load erfolgreich.
## Phasen-Überblick
```mermaid
flowchart TD
P1["Phase 1<br/>Domain-Fundament"]
P2["Phase 2<br/>Command-Schicht"]
P3["Phase 3<br/>Walking Skeleton<br/>(Konsole + handgebaute Welt)"]
P4["Phase 4<br/>YAML-Loading + Validator"]
P5["Phase 5<br/>Restliche Commands"]
P6["Phase 6<br/>NPCs end-to-end"]
P7["Phase 7<br/>Swing-GUI (Bonus)"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
```
## Checkliste Phase 1: Domain
- [x] `Direction` (`model.Direction`)
- [x] `Room` (`model.Room`)
- [x] `Item` abstract (`model.item.Item`) mit `abstract use(GameContext)`
- [x] `Npc` (`model.Npc`) inklusive `shell()`-Factory und `putReaction()`
- [x] `NpcReaction` (`model.NpcReaction`)
- [x] `GameIO` interface (`io.GameIO`)
- [x] `Player` (`model.Player`)
- [x] `World` (`model.World`)
- [x] `GameContext` (`game.GameContext`)
- [x] `ReadableItem` (`model.item.ReadableItem`)
- [x] `SwitchableItem` (`model.item.SwitchableItem`)
- [x] `PlainItem` (`model.item.PlainItem`)
## Checkliste Phase 2: Commands
- [x] `Command` interface (`command.Command`)
- [x] `ParsedCommand` (record)
- [x] `CommandRegistry` (`command.CommandRegistry`)
- [x] `CommandParser` (`command.CommandParser`) mit Filler-Words
- [x] `LookCommand`
- [x] `GoCommand`
- [x] `InventoryCommand`
## Checkliste Phase 3: Walking Skeleton
- [x] `ConsoleIO`
- [x] `Game` (Loop)
- [x] `App.main` (lädt sofort über YAML; der Walking-Skeleton-Zustand mit hartkodierter Welt wurde übersprungen, weil YAML-Load und Loop schon zusammen funktionieren)
- [x] Probelauf: `look`, `go north`, `inventory` (siehe `GameTest`, `LookCommandTest`)
## Checkliste Phase 4: YAML-Loading
- [x] DTOs: `GameDto`, `ItemDto`, `RoomDto`, `NpcDto`, `ReactionDto`
- [x] Test-Fixtures unter `src/test/resources/world/`
- [x] `WorldLoader` (Happy-Path)
- [x] `ReferenceResolver`
- [x] `WorldValidator` (eine Validierungsregel pro Test)
- [x] Echte Welt-YAMLs unter `src/main/resources/world/`
- [x] `App.main` läuft direkt gegen YAML-Load
## Checkliste Phase 5: Restliche Commands
- [x] `TakeCommand`
- [x] `DropCommand`
- [x] `UseCommand`
- [x] `ReadCommand`
- [x] `ExamineCommand`
- [x] `HelpCommand`
- [x] `QuitCommand`
## Checkliste Phase 6: NPCs
- [x] `Npc` voll ausgebaut (greeting, reactions)
- [x] `NpcReaction`
- [x] `TalkCommand`
- [x] `GiveCommand`
- [x] NPCs in `WorldLoader` integriert
- [x] End-to-End-Test: Lampe geben → Schlüssel bekommen (`TalkGiveCommandTest`)
## Checkliste Phase 7: Swing-GUI
- [x] `SwingIO` mit `LinkedBlockingQueue`-Brücke
- [x] `AppGui.main`
- [x] Game-Loop in Worker-Thread
## Build & Run
```sh
mvn test # 67 Tests
mvn -DskipTests exec:java -Dexec.mainClass=thb.jeanluc.adventure.App # Konsole
mvn -DskipTests exec:java -Dexec.mainClass=thb.jeanluc.adventure.AppGui # Swing
```
## Festgelegte Designentscheidungen
Nicht mehr offen, nicht nochmal diskutieren:
| Entscheidung | Wert |
|---|---|
| Item-Hierarchie | abstract Item + ReadableItem/SwitchableItem/PlainItem |
| Item-Package | `model.item` (Subpackage) |
| Switchable-State-Typ | `boolean` (kein Enum) |
| Switchable-Felder | nur `state` (Builder mappt YAML `initialState`); kein separates `initialState`-Feld am Domain-Objekt |
| use-Targets | argless `use X`, kein `use X on Y` |
| Item kennt Standort | nein, „dumme" Items |
| Hidden Items | nein |
| State→Raum-Beschreibung | nein im MVP |
| Room.description | `final`, immutable |
| Room.describe() | nicht auf Room, im LookCommand |
| Room.equals/hashCode | nicht überschreiben, Identity |
| Room-NPCs-Feld | von Anfang an drin |
| Bidirektionale Exits | manuell, kein Auto-Spiegeln |
| GameContext-Inhalt | minimal: World + Player + GameIO |
| IDs | lowercase snake_case slugs, kein UUID |
| ID-Regex | `^[a-z][a-z0-9_]*$` |
| YAML-Aufteilung | `game.yaml`, `items.yaml`, `rooms.yaml`, `npcs.yaml` |
| DTO ↔ Domain | getrennt, Resolver-Phase löst String-IDs zu Referenzen auf |
| Item-Type-Discriminator | YAML-Feld `type: plain|readable|switchable`, in `ItemFactory` als switch |
| Codebase-Sprache | Englisch (Identifier, YAML, User-Strings) |
| Doku-Sprache | Deutsch (Prose), Englisch (Code-Beispiele) |
| Diagramme | Mermaid |
| Lombok-Inheritance | `@SuperBuilder` |
| `@Data` | vermeiden, einzelne Annotations bevorzugen |
| Quit-Wiring | `QuitCommand.bind(Game)` nach Registry-Aufbau |
| Help-Quelle | `HelpCommand` zieht aus `CommandRegistry.distinctCommands()` |
| GameIO-Methodennamen | `readLine()` / `write(String)` (Java-üblich, konsistent) |
| Lombok-Version | 1.18.42 (1.18.36 ist nicht Java-26-kompatibel) |
## Offen / nicht im MVP
- **Win-Condition**: Spiel endet nur per `quit`. Optionale Erweiterung: Bedingung in `game.yaml` (`winRoom`, `requiredItem`).
- **Bedingte NPC-Reaktionen**, NPC-Memory, Quests — bewusst ausgelassen (siehe `npcs.md`).
- **Item-Aliases** (z.B. `lamp``oil_lamp`) — YAGNI bis konkreter Bedarf.
- **Eingabehistorie** in der GUI — `ArrayDeque` ist vorgesehen, nicht umgesetzt.

View File

@@ -0,0 +1,139 @@
# Item-Modell
Hierarchie aus abstraktem `Item` und drei konkreten Subtypen. Liegt im Subpackage `thb.jeanluc.adventure.model.item`.
## Hierarchie
```mermaid
classDiagram
class Item {
<<abstract>>
#String id
#String name
#String description
+use(GameContext)* void
}
class ReadableItem {
-String readText
+use(GameContext) void
}
class SwitchableItem {
-boolean state
-boolean initialState
-String onText
-String offText
+use(GameContext) void
+isOn() boolean
}
class PlainItem {
+use(GameContext) void
}
Item <|-- ReadableItem
Item <|-- SwitchableItem
Item <|-- PlainItem
```
## Felder pro Klasse
### `Item` (abstract)
| Feld | Typ | Hinweis |
|---|---|---|
| `id` | `String` | unique slug, Lookup-Key |
| `name` | `String` | Anzeigename |
| `description` | `String` | `examine`-Output |
Abstrakte Methode: `public abstract void use(GameContext ctx)`
### `ReadableItem`
| Feld | Typ | Hinweis |
|---|---|---|
| `readText` | `String` | wird beim `read`/`use` ausgegeben |
`use()` schreibt `readText` über `ctx.io()`.
### `SwitchableItem`
| Feld | Typ | Hinweis |
|---|---|---|
| `state` | `boolean` | aktueller Zustand (mutable) |
| `initialState` | `boolean` | aus YAML, im Konstruktor an `state` durchgereicht |
| `onText` | `String` | Nachricht beim Einschalten |
| `offText` | `String` | Nachricht beim Ausschalten |
`use()` toggelt `state` und schreibt den entsprechenden Text.
Bewusste Entscheidung: `boolean` statt `enum SwitchState`. Wenn später `BROKEN` o.ä. nötig wird, refactoren.
### `PlainItem`
Keine eigenen Felder. `use()` schreibt eine generische Nachricht („You can't use the X by itself."). Existiert, damit alle Items polymorph `use()` haben und das YAML einen konsistenten `type:`-Discriminator hat.
## Lombok-Setup
`@SuperBuilder` ist Pflicht, weil normales `@Builder` Vererbung nicht beherrscht.
```java
@Getter
@SuperBuilder
@RequiredArgsConstructor
public abstract class Item {
protected final String id;
protected final String name;
protected final String description;
public abstract void use(GameContext ctx);
}
@Getter
@SuperBuilder
public class ReadableItem extends Item {
private final String readText;
@Override
public void use(GameContext ctx) {
ctx.getIo().write(readText);
}
}
```
Konstruktion:
```java
ReadableItem.builder()
.id("letter").name("Letter").description("A crumpled paper.")
.readText("Meet me at midnight.")
.build();
```
## Jackson Polymorphism
```java
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = ReadableItem.class, name = "readable"),
@JsonSubTypes.Type(value = SwitchableItem.class, name = "switchable"),
@JsonSubTypes.Type(value = PlainItem.class, name = "plain")
})
public abstract class Item { ... }
```
YAML-Pflichtfeld pro Item: `type: readable|switchable|plain`.
## Verworfen / bewusst nicht im MVP
- **`use X on Y`** (targeted use) — Items kennen ihren Kontext über `GameContext`, kein zweites Item als Parameter.
- **Hidden Items** (sichtbar erst nach Aktion) — keine `visible`-Flag, alle Items sofort sichtbar.
- **Item kennt seinen Standort** — Items sind „dumm", nur Room/Player wissen wer sie hält.
- **Item-State beeinflusst Raumbeschreibung** (z.B. „Cellar dark unless lamp on") — wenn nötig, später per Conditions-System.
- **`hasBeenRead`-Tracking** — Lesen bleibt idempotent.
## Player-Input-Matching
Player tippt `take lamp` → Match gegen `id`. Multi-Word-Items haben snake_case ids, also `take oil_lamp`.
Alias-Feld (`aliases: [lamp, oil]`) ist YAGNI bis ein konkreter Bedarf entsteht.

View File

@@ -0,0 +1,78 @@
# Loading-Flow
Wie aus 4 YAML-Dateien eine spielbare `World` mit aufgelösten Referenzen wird.
## Phasen
```mermaid
flowchart TD
P1["<b>Phase 1: YAML → DTOs</b><br/>Jackson liest items.yaml, npcs.yaml,<br/>rooms.yaml, game.yaml in Records.<br/>Nur Daten, IDs als Strings."]
P2["<b>Phase 2: Domain-Shells erzeugen</b><br/>Pro DTO ein leeres Domain-Objekt.<br/>itemRegistry, npcRegistry, roomRegistry."]
P3["<b>Phase 3: Referenzen auflösen</b><br/>Room: items-ids → Item-Objekte<br/>Room: npcs-ids → Npc-Objekte<br/>Room: exits-Strings → Direction + Room-Ref<br/>Npc: reactions auflösen (gives, consumes)"]
P4["<b>Phase 4: Validierung</b><br/>Validierungsregeln aus yaml-schemas.md.<br/>Wirft WorldLoadException bei Verstoss."]
P5["<b>Phase 5: Player + World zusammenstellen</b><br/>Player mit startRoom + startGold.<br/>World für den Game-Loop bereit."]
P1 --> P2 --> P3 --> P4 --> P5
```
## Klassen-Aufteilung
| Klasse | Verantwortung |
|---|---|
| `WorldLoader` | Orchestriert die Phasen, public API: `World load()` |
| `ReferenceResolver` | Phase 3 isoliert (testbar) |
| `WorldValidator` | Phase 4 isoliert (testbar) |
| `WorldLoadException` | Eigene RuntimeException mit aussagekräftiger Message |
## Beispiel-Skizze
```java
public class WorldLoader {
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
public World load() {
// Phase 1
List<ItemDto> itemDtos = readList("/world/items.yaml", ItemDto.class);
List<NpcDto> npcDtos = readList("/world/npcs.yaml", NpcDto.class);
List<RoomDto> roomDtos = readList("/world/rooms.yaml", RoomDto.class);
GameDto gameDto = readSingle("/world/game.yaml", GameDto.class);
// Phase 2: Registries
Map<String, Item> items = itemDtos.stream()
.collect(Collectors.toMap(ItemDto::id, ItemFactory::fromDto));
Map<String, Npc> npcs = npcDtos.stream()
.collect(Collectors.toMap(NpcDto::id, NpcFactory::fromDto));
Map<String, Room> rooms = roomDtos.stream()
.collect(Collectors.toMap(RoomDto::id, RoomFactory::shellFromDto));
// Phase 3: Referenzen auflösen
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
resolver.resolveRooms(roomDtos);
resolver.resolveNpcs(npcDtos);
// Phase 4: validieren
new WorldValidator(items, npcs, rooms, gameDto).validate();
// Phase 5
Player player = new Player(rooms.get(gameDto.startRoom()), gameDto.startGold());
return new World(rooms, items, npcs, player, gameDto);
}
}
```
## Warum getrennte Phasen?
- **Testbarkeit:** Resolver und Validator können isoliert mit fertigen DTOs gefüttert werden, ohne YAML auf der Platte
- **Fehlerlokalisierung:** „Resolver hat versagt" vs. „Validator hat versagt" sind unterschiedliche Diagnosen
- **Zirkuläre Referenzen** (Raum A → Raum B → Raum A) werden problemlos: in Phase 2 sind beide Shells da, Phase 3 verlinkt
- **Fail-fast:** Spiel startet nicht mit kaputter Welt — besser als NullPointer beim ersten `go north`
## Tests pro Phase
| Test | Inhalt |
|---|---|
| `WorldLoaderTest` | Happy-Path: lädt Test-Fixtures aus `src/test/resources/world/` |
| `ReferenceResolverTest` | DTOs als Eingabe, prüft Auflösung |
| `WorldValidatorTest` | Pro Validierungsregel ein Test, der den Fehler triggert |
| `WorldValidatorTest.happy()` | Vollständig valide Welt darf nicht werfen |

View File

@@ -0,0 +1,119 @@
# NPCs
Nicht-Spieler-Figuren — optionaler Bonusteil laut Aufgabenstellung. Hier mit Talk- und Give-Interaktion modelliert.
## Domain-Modell
```mermaid
classDiagram
class Npc {
-String id
-String name
-String description
-String greeting
-Map~String, NpcReaction~ reactions
+talk(GameContext) void
+receive(Item, GameContext) boolean
}
class NpcReaction {
-Item consumes
-Item gives
-String response
+apply(Player) void
}
Npc "1" --> "*" NpcReaction : reactions
```
- `reactions` ist `HashMap<String, NpcReaction>` mit dem Trigger-Item-id als Key. O(1) Nachschlagen beim `gib X an Y`.
## Interaktionen
### `talk <npc>`
- Sucht NPC im aktuellen Raum.
- Wirft `greeting`-Text aus.
- Mutiert nichts.
### `gib <item> an <npc>`
```mermaid
sequenceDiagram
actor Spieler
participant Cmd as GiveCommand
participant P as Player
participant N as Npc
participant IO
Spieler->>Cmd: give lamp old_man
Cmd->>P: hasItem("lamp")?
P-->>Cmd: yes
Cmd->>N: receive(lamp)
N->>N: reactions.get("lamp")
alt Reaktion existiert
N->>P: inventory.remove(lamp)
N->>P: inventory.add(key)
N-->>Cmd: response text
Cmd->>IO: write(response)
else keine Reaktion
N-->>Cmd: false
Cmd->>IO: write("The NPC does not react.")
end
```
## YAML-Beispiel
```yaml
- id: old_man
name: Old Man
description: A stooped old man with a grey beard.
greeting: |
"Hello traveller. If you bring me the lamp,
I will show you the way to the cellar."
reactions:
- onReceive: lamp
response: |
"Thank you! Here, take this key."
gives: key
consumes: lamp
```
Felder einer Reaktion:
| Feld | Pflicht | Bedeutung |
|---|---|---|
| `onReceive` | ja | Item-id, das ausgelöst werden muss |
| `response` | ja | Antworttext nach erfolgreicher Übergabe |
| `gives` | nein | Item-id, das der NPC zurückgibt |
| `consumes` | nein | Item-id, das aus Spielerinventar entfernt wird (oft = `onReceive`) |
## Speichern in Räumen
Räume halten ihre NPCs analog zu Items:
```java
private final LinkedHashMap<String, Npc> npcs = new LinkedHashMap<>();
```
- `LinkedHashMap` weil O(1)-Lookup beim `talk <npc>` *und* stabile Reihenfolge in der Raumbeschreibung („Here is: Old Man, Innkeeper").
## Erweiterungen (bewusst nicht im MVP)
- Bedingte Dialoge („wenn Quest X erledigt, sag Y")
- Kauf/Verkauf mit Gold
- NPC bewegt sich zwischen Räumen
- Mehrfache Reaktionsketten (NPC-Memory)
Diese würden ein eigenes Quest-/Event-System rechtfertigen. Für 3 Bonuspunkte überdimensioniert.
## Validierungsregeln
Wiederholung aus [yaml-schemas.md](yaml-schemas.md), hier explizit pro NPC:
1. `id` eindeutig in `npcs.yaml`
2. `greeting` nicht leer (sonst sinnloses NPC)
3. Jede `onReceive`-id existiert in `items.yaml`
4. Jede `gives`-id existiert in `items.yaml`
5. Jede `consumes`-id existiert in `items.yaml`
6. Innerhalb eines NPCs ist `onReceive` eindeutig (keine zwei Reaktionen auf dasselbe Item)

View File

@@ -0,0 +1,143 @@
# YAML-Schemas
Alle Dateien liegen unter `src/main/resources/world/` und werden via Classpath geladen.
## `game.yaml`
Spiel-Konfiguration und Startbedingungen.
```yaml
title: Haunted Manor
version: 1.0
startRoom: kitchen
startGold: 0
welcomeMessage: |
Welcome to the Haunted Manor.
Type 'help' to see available commands.
```
**Felder:**
- `title` — Anzeigename des Spiels
- `version` — frei
- `startRoom` — id eines Raums aus `rooms.yaml`. Validierung: muss existieren.
- `startGold` — Start-Goldbetrag des Spielers
- `welcomeMessage` — wird beim Spielstart ausgegeben
## `items.yaml`
Liste aller Items. Items haben Zustand (`state`-Feld, optional), der zur Laufzeit verändert werden kann.
```yaml
- id: letter
name: Letter
description: A crumpled piece of paper.
readable: true
readText: |
"Meet me at midnight in the cellar. - A."
- id: lamp
name: Oil Lamp
description: An old oil lamp.
switchable: true
initialState: off # off | on
- id: shovel
name: Shovel
description: A rusty shovel.
- id: key
name: Key
description: A small brass key.
```
**Felder:**
- `id` — eindeutig, klein geschrieben, ohne Sonderzeichen
- `name` — Anzeigename
- `description` — Beschreibung beim `untersuche` / `lies`
- `readable`, `switchable`, ... — feature-Flags für `Use`-Verhalten
**Hinweis:** Die genauen Felder hängen vom Item-Modell ab. Alternative: alle Items haben dieselben Felder, das Verhalten wird durch eine `behavior`-Discriminator-Property gesteuert (`behavior: readable` / `behavior: switchable`). Pro/Contra in der Implementierung entscheiden.
## `rooms.yaml`
Liste aller Räume. Items und NPCs werden per id referenziert, nicht eingebettet.
```yaml
- id: kitchen
name: Old Kitchen
description: |
A dusty kitchen. A letter lies on the table.
A door leads north.
exits:
north: hallway
east: cellar
items: [letter, lamp]
npcs: [old_man]
- id: hallway
name: Dark Hallway
description: |
A long, dimly lit hallway. It smells musty.
exits:
south: kitchen
west: library
items: []
npcs: []
- id: library
name: Library
description: |
Tall shelves full of books. An old chest
stands in one corner.
exits:
east: hallway
items: [shovel]
npcs: []
- id: cellar
name: Damp Cellar
description: |
It is cold and damp. You need a light source.
exits:
west: kitchen
items: [key]
npcs: []
```
**Felder:**
- `id`, `name`, `description` — analog Items
- `exits` — Map von Richtungs-String auf Raum-id. Richtungs-Strings müssen zu `Direction`-Enum parsbar sein.
- `items`, `npcs` — Listen von ids aus den jeweiligen Registries
## `npcs.yaml`
Liste aller NPCs. Detaillierte Interaktion siehe [npcs.md](npcs.md).
```yaml
- id: old_man
name: Old Man
description: A stooped old man with a grey beard.
greeting: |
"Hello traveller. If you bring me the lamp,
I will show you the way to the cellar."
reactions:
- onReceive: lamp
response: |
"Thank you! Here, take this key."
gives: key
consumes: lamp
```
## Validierungsregeln (zentral)
Beim Loading wird geprüft (siehe [loading-flow.md](loading-flow.md)):
1. Alle ids sind eindeutig innerhalb ihrer Datei.
2. Jede in `rooms.yaml` referenzierte Item-id existiert in `items.yaml`.
3. Jede in `rooms.yaml` referenzierte NPC-id existiert in `npcs.yaml`.
4. Jede `exits`-Ziel-id existiert in `rooms.yaml`.
5. Jeder `exits`-Richtungs-String ist zu `Direction` parsbar.
6. `game.yaml.startRoom` existiert.
7. NPC-Reaktionen: `onReceive`, `gives`, `consumes` referenzieren existierende Item-ids.
Bei Verstoss → Exception, Spielstart bricht ab.