diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore new file mode 100644 index 0000000..480bdf5 --- /dev/null +++ b/Semesterprojekt/.gitignore @@ -0,0 +1,39 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/Semesterprojekt/docs/README.md b/Semesterprojekt/docs/README.md new file mode 100644 index 0000000..5112969 --- /dev/null +++ b/Semesterprojekt/docs/README.md @@ -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) diff --git a/Semesterprojekt/docs/architecture.md b/Semesterprojekt/docs/architecture.md new file mode 100644 index 0000000..7485f51 --- /dev/null +++ b/Semesterprojekt/docs/architecture.md @@ -0,0 +1,93 @@ +# Architektur + +## Schichten + +```mermaid +flowchart TD + IO["io (Konsole / GUI)
Interaktion mit Spieler"] + GAME["game (Engine/Loop)
Spielfluss, hält World + Player"] + CMD["command (Commands)
go, take, use, talk, ..."] + MODEL["model (Domain)
Room, Item, Player, Npc, World"] + LOADER["loader (YAML + DTO)
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
AppGui.java"] + root --> model + root --> loader + root --> command + root --> game + root --> io + + model --> model_files["World, Room,
Player, Npc, Direction"] + model --> item + item --> item_files["Item (abstract)
ReadableItem
SwitchableItem
PlainItem"] + + loader --> loader_files["WorldLoader
ReferenceResolver
WorldValidator"] + loader --> dto + dto --> dto_files["RoomDto, ItemDto,
NpcDto, GameDto"] + + command --> cmd_core["Command (Interface)
CommandRegistry
CommandParser"] + command --> impl + impl --> impl_files["GoCommand, TakeCommand,
DropCommand, UseCommand,
InventoryCommand, LookCommand,
TalkCommand, GiveCommand,
HelpCommand, QuitCommand"] + + game --> game_files["Game (Loop)
GameContext"] + + io --> io_files["GameIO (Interface)
ConsoleIO
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` (typisicher, schnell) statt `Map` (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`, die vom JTextField-ActionListener gefüllt wird. diff --git a/Semesterprojekt/docs/commands.md b/Semesterprojekt/docs/commands.md new file mode 100644 index 0000000..cb33d2f --- /dev/null +++ b/Semesterprojekt/docs/commands.md @@ -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 args); + + /** Short help text for the 'help' command. */ + String help(); +} +``` + +## Registry + +```java +public class CommandRegistry { + private final Map commands = new HashMap<>(); + + public void register(Command cmd, String... names) { + for (String n : names) { + commands.put(n.toLowerCase(), cmd); + } + } + + public Optional 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 args) {} + +public class CommandParser { + private static final Set 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 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 ` | `move`, `walk` | Spieler wechselt Raum | Pflicht | +| `take ` | `pick`, `get` | Item aus Raum ins Inventar | Pflicht | +| `drop ` | `put` | Item aus Inventar in Raum | sinnvoll | +| `use ` | — | Item-spezifische Aktion | Pflicht | +| `read ` | — | Spezialfall von use für `readable` | Pflicht | +| `inventory` | `inv`, `i` | Inventar anzeigen | sinnvoll | +| `look` | `l` | Raumbeschreibung wiederholen | sinnvoll | +| `examine ` | `x` | Item-Beschreibung anzeigen | sinnvoll | +| `talk ` | `speak` | NPC-Greeting ausgeben | NPC-Bonus | +| `give ` | — | 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); +} +``` diff --git a/Semesterprojekt/docs/conventions.md b/Semesterprojekt/docs/conventions.md new file mode 100644 index 0000000..573f296 --- /dev/null +++ b/Semesterprojekt/docs/conventions.md @@ -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). diff --git a/Semesterprojekt/docs/data-structures.md b/Semesterprojekt/docs/data-structures.md new file mode 100644 index 0000000..3cff7dc --- /dev/null +++ b/Semesterprojekt/docs/data-structures.md @@ -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` | O(1) | +| Alle Räume der Welt | `HashMap` | O(1) | +| Item-Registry (global) | `HashMap` | O(1) | +| NPC-Registry (global) | `HashMap` | O(1) | +| Items in einem Raum | `LinkedHashMap` | O(1) | +| NPCs in einem Raum | `LinkedHashMap` | O(1) | +| Spieler-Inventar | `LinkedHashMap` | O(1) | +| Befehlsregistry | `HashMap` | O(1) | +| NPC-Reaktionen | `HashMap` | O(1) | +| Eingabehistorie (optional) | `ArrayDeque` | O(1) Front/Back | + +## Begründungen im Detail + +### `EnumMap` 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` 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` 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` 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` 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` 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` für Inventar | O(n)-Lookup, Duplikat-Handling nötig | +| `HashMap` 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` 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` (`ArrayBlockingQueue` reicht) als Brücke ins Spiel — siehe [architecture.md](architecture.md). diff --git a/Semesterprojekt/docs/implementation-status.md b/Semesterprojekt/docs/implementation-status.md new file mode 100644 index 0000000..8d1102d --- /dev/null +++ b/Semesterprojekt/docs/implementation-status.md @@ -0,0 +1,135 @@ +# Implementierungsstand & Reihenfolge + +Stand: alle Phasen 1–7 implementiert, 67 Tests grün, End-to-End-Smoke-Test des Walking-Skeletons + YAML-Load erfolgreich. + +## Phasen-Überblick + +```mermaid +flowchart TD + P1["Phase 1
Domain-Fundament"] + P2["Phase 2
Command-Schicht"] + P3["Phase 3
Walking Skeleton
(Konsole + handgebaute Welt)"] + P4["Phase 4
YAML-Loading + Validator"] + P5["Phase 5
Restliche Commands"] + P6["Phase 6
NPCs end-to-end"] + P7["Phase 7
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. diff --git a/Semesterprojekt/docs/item-model.md b/Semesterprojekt/docs/item-model.md new file mode 100644 index 0000000..b73f64f --- /dev/null +++ b/Semesterprojekt/docs/item-model.md @@ -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 { + <> + #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. diff --git a/Semesterprojekt/docs/loading-flow.md b/Semesterprojekt/docs/loading-flow.md new file mode 100644 index 0000000..7635cad --- /dev/null +++ b/Semesterprojekt/docs/loading-flow.md @@ -0,0 +1,78 @@ +# Loading-Flow + +Wie aus 4 YAML-Dateien eine spielbare `World` mit aufgelösten Referenzen wird. + +## Phasen + +```mermaid +flowchart TD + P1["Phase 1: YAML → DTOs
Jackson liest items.yaml, npcs.yaml,
rooms.yaml, game.yaml in Records.
Nur Daten, IDs als Strings."] + P2["Phase 2: Domain-Shells erzeugen
Pro DTO ein leeres Domain-Objekt.
itemRegistry, npcRegistry, roomRegistry."] + P3["Phase 3: Referenzen auflösen
Room: items-ids → Item-Objekte
Room: npcs-ids → Npc-Objekte
Room: exits-Strings → Direction + Room-Ref
Npc: reactions auflösen (gives, consumes)"] + P4["Phase 4: Validierung
Validierungsregeln aus yaml-schemas.md.
Wirft WorldLoadException bei Verstoss."] + P5["Phase 5: Player + World zusammenstellen
Player mit startRoom + startGold.
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 itemDtos = readList("/world/items.yaml", ItemDto.class); + List npcDtos = readList("/world/npcs.yaml", NpcDto.class); + List roomDtos = readList("/world/rooms.yaml", RoomDto.class); + GameDto gameDto = readSingle("/world/game.yaml", GameDto.class); + + // Phase 2: Registries + Map items = itemDtos.stream() + .collect(Collectors.toMap(ItemDto::id, ItemFactory::fromDto)); + Map npcs = npcDtos.stream() + .collect(Collectors.toMap(NpcDto::id, NpcFactory::fromDto)); + Map 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 | diff --git a/Semesterprojekt/docs/npcs.md b/Semesterprojekt/docs/npcs.md new file mode 100644 index 0000000..9164e1b --- /dev/null +++ b/Semesterprojekt/docs/npcs.md @@ -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` mit dem Trigger-Item-id als Key. O(1) Nachschlagen beim `gib X an Y`. + +## Interaktionen + +### `talk ` + +- Sucht NPC im aktuellen Raum. +- Wirft `greeting`-Text aus. +- Mutiert nichts. + +### `gib an ` + +```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 npcs = new LinkedHashMap<>(); +``` + +- `LinkedHashMap` weil O(1)-Lookup beim `talk ` *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) diff --git a/Semesterprojekt/docs/yaml-schemas.md b/Semesterprojekt/docs/yaml-schemas.md new file mode 100644 index 0000000..7b8fe6f --- /dev/null +++ b/Semesterprojekt/docs/yaml-schemas.md @@ -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. diff --git a/Semesterprojekt/pom.xml b/Semesterprojekt/pom.xml new file mode 100644 index 0000000..f449b36 --- /dev/null +++ b/Semesterprojekt/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + thb.jeanluc + Semesterprojekt + 1.0-SNAPSHOT + + + 25 + 25 + UTF-8 + + 2.18.2 + 1.18.42 + 5.11.4 + 3.27.0 + 5.14.2 + 1.5.15 + + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + + diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java new file mode 100644 index 0000000..8b74bfb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -0,0 +1,86 @@ +package thb.jeanluc.adventure; + +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.DropCommand; +import thb.jeanluc.adventure.command.impl.ExamineCommand; +import thb.jeanluc.adventure.command.impl.GiveCommand; +import thb.jeanluc.adventure.command.impl.GoCommand; +import thb.jeanluc.adventure.command.impl.HelpCommand; +import thb.jeanluc.adventure.command.impl.InventoryCommand; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.command.impl.QuitCommand; +import thb.jeanluc.adventure.command.impl.ReadCommand; +import thb.jeanluc.adventure.command.impl.TakeCommand; +import thb.jeanluc.adventure.command.impl.TalkCommand; +import thb.jeanluc.adventure.command.impl.UseCommand; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.ConsoleIO; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.loader.WorldLoader; + +/** + * Entry point for the console version of the game. Loads the world from + * YAML, wires up the command registry, and hands control to the + * {@link Game} loop. + */ +@Slf4j +public final class App { + + private App() { + } + + /** + * Standard JVM entry point. + * + * @param args ignored + */ + public static void main(String[] args) { + GameIO io = new ConsoleIO(); + try { + run(io); + } catch (RuntimeException e) { + log.error("Fatal error during game startup", e); + io.write("Fatal error: " + e.getMessage()); + System.exit(1); + } + } + + /** + * Boots the game on the given IO channel. Reusable by both + * {@link App} (console) and {@code AppGui} (Swing worker thread). + * + * @param io the IO channel to use + */ + public static void run(GameIO io) { + WorldLoader.LoadResult loaded = new WorldLoader().load(); + GameContext ctx = new GameContext(loaded.world(), loaded.player(), io); + + CommandRegistry registry = new CommandRegistry(); + registry.register(new GoCommand(), "go", "move", "walk"); + registry.register(new LookCommand(), "look", "l"); + registry.register(new InventoryCommand(), "inventory", "inv", "i"); + registry.register(new TakeCommand(), "take", "pick", "get"); + registry.register(new DropCommand(), "drop", "put"); + registry.register(new UseCommand(), "use"); + registry.register(new ReadCommand(), "read"); + registry.register(new ExamineCommand(), "examine", "x", "inspect"); + registry.register(new TalkCommand(), "talk", "speak"); + registry.register(new GiveCommand(), "give"); + registry.register(new HelpCommand(registry), "help", "?"); + + QuitCommand quit = new QuitCommand(); + registry.register(quit, "quit", "exit"); + + Game game = new Game(ctx, registry, new CommandParser()); + quit.bind(game); + + io.write(loaded.world().getTitle()); + io.write(loaded.world().getWelcomeMessage()); + new LookCommand().execute(ctx, java.util.List.of()); + + game.run(); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java new file mode 100644 index 0000000..d4a0a73 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java @@ -0,0 +1,40 @@ +package thb.jeanluc.adventure; + +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.io.SwingIO; + +import javax.swing.SwingUtilities; + +/** + * Entry point for the Swing version of the game. The Swing window is + * built on the Event Dispatch Thread; the game loop itself runs in a + * background worker so that EDT events (typing) and blocking + * {@code readLine()} calls never deadlock each other. + */ +@Slf4j +public final class AppGui { + + private AppGui() { + } + + /** + * Standard JVM entry point. + * + * @param args ignored + */ + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + SwingIO io = new SwingIO("Haunted Manor"); + Thread worker = new Thread(() -> { + try { + App.run(io); + } catch (RuntimeException e) { + log.error("Fatal error during game startup", e); + io.write("Fatal error: " + e.getMessage()); + } + }, "game-loop"); + worker.setDaemon(true); + worker.start(); + }); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/Command.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/Command.java new file mode 100644 index 0000000..15fcfb1 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/Command.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.command; + +import thb.jeanluc.adventure.game.GameContext; + +import java.util.List; + +/** + * A single in-game verb. Implementations are stateless and registered + * (one or many aliases) in {@link CommandRegistry}. + */ +public interface Command { + + /** + * Executes the command. + * + * @param ctx active game context + * @param args parser-tokenised argument list (filler words removed) + */ + void execute(GameContext ctx, List args); + + /** + * Short single-line help text shown by the {@code help} command. + * + * @return human-readable usage hint + */ + String help(); +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/CommandParser.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/CommandParser.java new file mode 100644 index 0000000..8e931eb --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/CommandParser.java @@ -0,0 +1,38 @@ +package thb.jeanluc.adventure.command; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +/** + * Tokenising parser for player input. Splits on whitespace, lowercases, + * then drops a small set of grammatical filler words so that + * {@code go to the north} parses the same as {@code go north}. + */ +public class CommandParser { + + /** + * Filler tokens removed from the argument list. Kept deliberately + * small — when adding more, watch out for words that might be valid + * item ids ({@code key}, {@code lamp}, etc.). + */ + private static final Set FILLERS = Set.of("to", "with", "at", "the", "a", "an", "on"); + + /** + * Parses an input line into a verb and its arguments. + * + * @param input raw player input; may be null or blank + * @return parsed command; verb is the empty string if input is blank + */ + public ParsedCommand parse(String input) { + if (input == null || input.isBlank()) { + return new ParsedCommand("", List.of()); + } + String[] tokens = input.trim().toLowerCase().split("\\s+"); + String verb = tokens[0]; + List args = Arrays.stream(tokens, 1, tokens.length) + .filter(t -> !FILLERS.contains(t)) + .toList(); + return new ParsedCommand(verb, args); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/CommandRegistry.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/CommandRegistry.java new file mode 100644 index 0000000..83243f5 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/CommandRegistry.java @@ -0,0 +1,54 @@ +package thb.jeanluc.adventure.command; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Verb-to-{@link Command} dispatch table. Backed by a {@link HashMap} + * (O(1) lookup), with each command optionally registered under multiple + * aliases. + */ +public class CommandRegistry { + + /** Lookup table; multiple aliases may map to the same command instance. */ + private final Map commands = new HashMap<>(); + + /** + * Registers a command under one or more verbs (aliases). All names + * are stored lowercase to keep dispatch case-insensitive. + * + * @param cmd the command to register + * @param names one or more verb aliases + */ + public void register(Command cmd, String... names) { + for (String n : names) { + commands.put(n.toLowerCase(), cmd); + } + } + + /** + * Looks up a command by verb. + * + * @param verb verb token, case-insensitive + * @return the matching command, or empty if no command is registered + */ + public Optional find(String verb) { + if (verb == null) { + return Optional.empty(); + } + return Optional.ofNullable(commands.get(verb.toLowerCase())); + } + + /** + * Returns the set of distinct command instances (deduplicated across + * aliases). Used by {@code HelpCommand} to list help texts. + * + * @return distinct commands in insertion order + */ + public Set distinctCommands() { + return new LinkedHashSet<>(commands.values()); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/ParsedCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/ParsedCommand.java new file mode 100644 index 0000000..e210a20 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/ParsedCommand.java @@ -0,0 +1,14 @@ +package thb.jeanluc.adventure.command; + +import java.util.List; + +/** + * Result of tokenising a player input line. The first non-empty token is + * the verb, all remaining tokens (filler words filtered) become the + * argument list. + * + * @param verb verb token, lowercase; empty string for blank input + * @param args remaining argument tokens, lowercase, filler words removed + */ +public record ParsedCommand(String verb, List args) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/DropCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/DropCommand.java new file mode 100644 index 0000000..8d5dcc9 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/DropCommand.java @@ -0,0 +1,36 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Optional; + +/** + * Drops an item from the inventory back into the current room. + * Usage: {@code drop }. + */ +public class DropCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Drop what?"); + return; + } + String itemId = args.getFirst(); + Optional dropped = ctx.getPlayer().removeItem(itemId); + if (dropped.isEmpty()) { + ctx.getIo().write("You are not carrying '" + itemId + "'."); + return; + } + ctx.getPlayer().getCurrentRoom().addItem(dropped.get()); + ctx.getIo().write("You drop the " + dropped.get().getName() + "."); + } + + @Override + public String help() { + return "drop - put an item from your inventory into the room"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java new file mode 100644 index 0000000..17ed54f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ExamineCommand.java @@ -0,0 +1,44 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Optional; + +/** + * Shows the description of an item or NPC. The target may be in the + * player's inventory or in the current room. Usage: {@code examine }. + */ +public class ExamineCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Examine what?"); + return; + } + String id = args.getFirst(); + Optional item = ctx.getPlayer().findItem(id); + if (item.isEmpty()) { + item = ctx.getPlayer().getCurrentRoom().findItem(id); + } + if (item.isPresent()) { + ctx.getIo().write(item.get().getDescription()); + return; + } + Optional npc = ctx.getPlayer().getCurrentRoom().findNpc(id); + if (npc.isPresent()) { + ctx.getIo().write(npc.get().getDescription()); + return; + } + ctx.getIo().write("There is no '" + id + "' here."); + } + + @Override + public String help() { + return "examine - inspect an item or NPC (alias: x)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java new file mode 100644 index 0000000..8c3ac00 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GiveCommand.java @@ -0,0 +1,60 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.NpcReaction; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Optional; + +/** + * Offers an item from the inventory to an NPC. If the NPC has a matching + * reaction, the item is consumed (when {@code consumes} is set) and the + * NPC may give back another item. Usage: {@code give }. + */ +public class GiveCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.size() < 2) { + ctx.getIo().write("Usage: give ."); + return; + } + String itemId = args.get(0); + String npcId = args.get(1); + + Optional held = ctx.getPlayer().findItem(itemId); + if (held.isEmpty()) { + ctx.getIo().write("You are not carrying '" + itemId + "'."); + return; + } + Optional npcOpt = ctx.getPlayer().getCurrentRoom().findNpc(npcId); + if (npcOpt.isEmpty()) { + ctx.getIo().write("There is no '" + npcId + "' here."); + return; + } + + Npc npc = npcOpt.get(); + Optional reactionOpt = npc.reactionFor(itemId); + if (reactionOpt.isEmpty()) { + ctx.getIo().write(npc.getName() + " does not react to the " + held.get().getName() + "."); + return; + } + + NpcReaction reaction = reactionOpt.get(); + if (reaction.getConsumes() != null) { + ctx.getPlayer().removeItem(reaction.getConsumes().getId()); + } + if (reaction.getGives() != null) { + ctx.getPlayer().addItem(reaction.getGives()); + } + ctx.getIo().write(npc.getName() + ": " + reaction.getResponse()); + } + + @Override + public String help() { + return "give - hand an item to an NPC"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java new file mode 100644 index 0000000..e790bd5 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java @@ -0,0 +1,43 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Room; + +import java.util.List; +import java.util.Optional; + +/** + * Moves the player into the room reachable in a given direction. + * Usage: {@code go }. + */ +public class GoCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Go where? Try 'go north'."); + return; + } + Direction dir; + try { + dir = Direction.fromString(args.getFirst()); + } catch (IllegalArgumentException e) { + ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is."); + return; + } + Optional next = ctx.getPlayer().getCurrentRoom().getExit(dir); + if (next.isEmpty()) { + ctx.getIo().write("You can't go " + dir.getLabel() + " from here."); + return; + } + ctx.getPlayer().setCurrentRoom(next.get()); + new LookCommand().execute(ctx, List.of()); + } + + @Override + public String help() { + return "go - move to the connected room (north/south/east/west)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/HelpCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/HelpCommand.java new file mode 100644 index 0000000..c25a61e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/HelpCommand.java @@ -0,0 +1,34 @@ +package thb.jeanluc.adventure.command.impl; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.game.GameContext; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Lists all available commands and their short help text. Pulls the + * command set from the {@link CommandRegistry} so it stays in sync + * automatically. Usage: {@code help}. + */ +@RequiredArgsConstructor +public class HelpCommand implements Command { + + /** Registry that owns the commands to be listed. */ + private final CommandRegistry registry; + + @Override + public void execute(GameContext ctx, List args) { + String body = registry.distinctCommands().stream() + .map(Command::help) + .collect(Collectors.joining("\n ")); + ctx.getIo().write("Available commands:\n " + body); + } + + @Override + public String help() { + return "help - show this list (alias: ?)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/InventoryCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/InventoryCommand.java new file mode 100644 index 0000000..569b518 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/InventoryCommand.java @@ -0,0 +1,33 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Lists the items the player is currently carrying. + * Usage: {@code inventory}. + */ +public class InventoryCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + var inventory = ctx.getPlayer().getInventory(); + if (inventory.isEmpty()) { + ctx.getIo().write("You are not carrying anything."); + return; + } + String list = inventory.values().stream() + .map(Item::getName) + .collect(Collectors.joining(", ")); + ctx.getIo().write("You are carrying: " + list + "."); + } + + @Override + public String help() { + return "inventory - list what you are carrying (alias: inv, i)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java new file mode 100644 index 0000000..ecc918d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java @@ -0,0 +1,54 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Prints a full description of the room the player is currently in: + * name, description, visible items, visible NPCs, and available exits. + * Usage: {@code look}. + */ +public class LookCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + Room room = ctx.getPlayer().getCurrentRoom(); + StringBuilder sb = new StringBuilder(); + sb.append("== ").append(room.getName()).append(" ==\n"); + sb.append(room.getDescription().stripTrailing()); + + if (!room.getItems().isEmpty()) { + String items = room.getItems().values().stream() + .map(Item::getName) + .collect(Collectors.joining(", ")); + sb.append("\nYou see: ").append(items).append('.'); + } + if (!room.getNpcs().isEmpty()) { + String npcs = room.getNpcs().values().stream() + .map(Npc::getName) + .collect(Collectors.joining(", ")); + sb.append("\nHere is: ").append(npcs).append('.'); + } + if (room.getExits().isEmpty()) { + sb.append("\nThere are no obvious exits."); + } else { + String exits = room.getExits().keySet().stream() + .map(Direction::getLabel) + .collect(Collectors.joining(", ")); + sb.append("\nExits: ").append(exits).append('.'); + } + ctx.getIo().write(sb.toString()); + } + + @Override + public String help() { + return "look - describe the current room"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java new file mode 100644 index 0000000..7282498 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/QuitCommand.java @@ -0,0 +1,41 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.Game; +import thb.jeanluc.adventure.game.GameContext; + +import java.util.List; + +/** + * Ends the game loop. Holds a reference to the {@link Game} so it can + * flip the loop flag. Usage: {@code quit}. + */ +public class QuitCommand implements Command { + + /** The game whose loop is to be stopped. Set via {@link #bind(Game)}. */ + private Game game; + + /** + * Binds the game instance after construction. Two-phase wiring is + * necessary because the registry is typically built before the game + * starts running. + * + * @param game the running game; must not be null + */ + public void bind(Game game) { + this.game = game; + } + + @Override + public void execute(GameContext ctx, List args) { + ctx.getIo().write("Goodbye."); + if (game != null) { + game.stop(); + } + } + + @Override + public String help() { + return "quit - exit the game (alias: exit)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.java new file mode 100644 index 0000000..5f554c3 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/ReadCommand.java @@ -0,0 +1,44 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.ReadableItem; + +import java.util.List; +import java.util.Optional; + +/** + * Shorthand for using a {@link ReadableItem}. Behaves like + * {@link UseCommand} but writes a more helpful message when applied to a + * non-readable item. Usage: {@code read }. + */ +public class ReadCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Read what?"); + return; + } + String itemId = args.getFirst(); + Optional item = ctx.getPlayer().findItem(itemId); + if (item.isEmpty()) { + item = ctx.getPlayer().getCurrentRoom().findItem(itemId); + } + if (item.isEmpty()) { + ctx.getIo().write("There is no '" + itemId + "' here or in your inventory."); + return; + } + if (item.get() instanceof ReadableItem readable) { + readable.use(ctx); + } else { + ctx.getIo().write("There is nothing to read on the " + item.get().getName() + "."); + } + } + + @Override + public String help() { + return "read - read the text of a readable item"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java new file mode 100644 index 0000000..d28721f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TakeCommand.java @@ -0,0 +1,38 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Optional; + +/** + * Picks an item up from the current room and places it in the inventory. + * Usage: {@code take }. + */ +public class TakeCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Take what?"); + return; + } + String itemId = args.getFirst(); + Room room = ctx.getPlayer().getCurrentRoom(); + Optional taken = room.removeItem(itemId); + if (taken.isEmpty()) { + ctx.getIo().write("There is no '" + itemId + "' here."); + return; + } + ctx.getPlayer().addItem(taken.get()); + ctx.getIo().write("You take the " + taken.get().getName() + "."); + } + + @Override + public String help() { + return "take - pick up an item from the room (alias: pick, get)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java new file mode 100644 index 0000000..30409b6 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/TalkCommand.java @@ -0,0 +1,34 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.Npc; + +import java.util.List; +import java.util.Optional; + +/** + * Prints an NPC's greeting text. Usage: {@code talk }. + */ +public class TalkCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Talk to whom?"); + return; + } + String npcId = args.getFirst(); + Optional npc = ctx.getPlayer().getCurrentRoom().findNpc(npcId); + if (npc.isEmpty()) { + ctx.getIo().write("There is no '" + npcId + "' here to talk to."); + return; + } + ctx.getIo().write(npc.get().getName() + " says: " + npc.get().getGreeting()); + } + + @Override + public String help() { + return "talk - start a conversation with an NPC (alias: speak)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java new file mode 100644 index 0000000..642d0d6 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java @@ -0,0 +1,39 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Optional; + +/** + * Invokes the item-specific {@link Item#use(GameContext)} action. The + * item must either be in the player's inventory or in the current room. + * Usage: {@code use }. + */ +public class UseCommand implements Command { + + @Override + public void execute(GameContext ctx, List args) { + if (args.isEmpty()) { + ctx.getIo().write("Use what?"); + return; + } + String itemId = args.getFirst(); + Optional item = ctx.getPlayer().findItem(itemId); + if (item.isEmpty()) { + item = ctx.getPlayer().getCurrentRoom().findItem(itemId); + } + if (item.isEmpty()) { + ctx.getIo().write("There is no '" + itemId + "' here or in your inventory."); + return; + } + item.get().use(ctx); + } + + @Override + public String help() { + return "use - use an item (read it, switch it on/off, ...)"; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java new file mode 100644 index 0000000..046c3d6 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -0,0 +1,59 @@ +package thb.jeanluc.adventure.game; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.command.Command; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.ParsedCommand; + +import java.util.Optional; + +/** + * The main game loop. Reads a line, parses it, dispatches the matching + * command, and writes the result — until {@link #stop()} flips the + * {@link #running} flag (typically by the quit command or EOF on input). + */ +@RequiredArgsConstructor +public class Game { + + /** Game context shared with every command. */ + private final GameContext ctx; + + /** Command lookup table. */ + private final CommandRegistry registry; + + /** Tokenising parser for player input. */ + private final CommandParser parser; + + /** Loop flag. Flipped to false by {@link #stop()}. */ + private boolean running = true; + + /** + * Runs the loop until {@link #stop()} is called or input is exhausted. + */ + public void run() { + while (running) { + String input = ctx.getIo().readLine(); + if (input == null) { + break; + } + ParsedCommand parsed = parser.parse(input); + if (parsed.verb().isEmpty()) { + continue; + } + Optional cmd = registry.find(parsed.verb()); + if (cmd.isEmpty()) { + ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); + } else { + cmd.get().execute(ctx, parsed.args()); + } + } + } + + /** + * Signals the loop to terminate after the current iteration. + */ + public void stop() { + running = false; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java new file mode 100644 index 0000000..7f3f41c --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/GameContext.java @@ -0,0 +1,26 @@ +package thb.jeanluc.adventure.game; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.World; + +/** + * Bundle of everything a command needs to do its work: the world, the + * player, and the IO channel. Passing this single object keeps command + * signatures small and consistent. + */ +@Getter +@RequiredArgsConstructor +public class GameContext { + + /** Loaded world (rooms, items, npcs). */ + private final World world; + + /** The player avatar. */ + private final Player player; + + /** IO channel for reading input and writing output. */ + private final GameIO io; +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java new file mode 100644 index 0000000..bce6c4d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -0,0 +1,57 @@ +package thb.jeanluc.adventure.io; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; + +/** + * {@link GameIO} backed by {@code System.in} and {@code System.out}. + * Each {@code write} appends a newline so output blocks are visually + * separated. {@code readLine} prints a {@code > } prompt before + * blocking. + */ +public class ConsoleIO implements GameIO { + + /** Input source. */ + private final BufferedReader in; + + /** Output sink. */ + private final PrintStream out; + + /** + * Creates a console IO using standard input and output. + */ + public ConsoleIO() { + this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out); + } + + /** + * Test-friendly constructor allowing custom streams. + * + * @param in reader to consume player input + * @param out stream to write player output + */ + public ConsoleIO(BufferedReader in, PrintStream out) { + this.in = in; + this.out = out; + } + + @Override + public String readLine() { + out.print("> "); + out.flush(); + try { + String line = in.readLine(); + return line == null ? "" : line; + } catch (IOException e) { + return ""; + } + } + + @Override + public void write(String s) { + out.println(s); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java new file mode 100644 index 0000000..b4b2ea2 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -0,0 +1,24 @@ +package thb.jeanluc.adventure.io; + +/** + * Bidirectional text channel between the engine and the player. Implemented + * by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic + * to the actual interface. + */ +public interface GameIO { + + /** + * Blocks until a line of input is available. + * + * @return the next user input line, or an empty string at end of input + */ + String readLine(); + + /** + * Writes a message to the player. Each call corresponds to one logical + * output block; the implementation handles line breaks at the boundary. + * + * @param s message text; must not be null + */ + void write(String s); +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java new file mode 100644 index 0000000..70ec556 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -0,0 +1,85 @@ +package thb.jeanluc.adventure.io; + +import javax.swing.BorderFactory; +import javax.swing.JFrame; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Font; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * {@link GameIO} backed by a Swing window. A read-only output area shows + * game text; a bottom text field collects player input. The input from + * the EDT is handed to the (worker-thread) game loop through a + * {@link LinkedBlockingQueue}. + */ +public class SwingIO implements GameIO { + + /** Bridge between the EDT and the game loop. One element = one line. */ + private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); + + /** Main window. */ + private final JFrame frame; + + /** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */ + private final JTextArea output; + + /** Input text field; ENTER pushes the line into {@link #inputs}. */ + private final JTextField input; + + /** + * Creates and shows the window with the given title. Must be called on + * the EDT (typical pattern: from {@code SwingUtilities.invokeLater}). + * + * @param title window title + */ + public SwingIO(String title) { + frame = new JFrame(title); + output = new JTextArea(); + input = new JTextField(); + + output.setEditable(false); + output.setLineWrap(true); + output.setWrapStyleWord(true); + output.setBackground(Color.BLACK); + output.setForeground(Color.LIGHT_GRAY); + output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + input.addActionListener(e -> { + String line = input.getText(); + input.setText(""); + output.append("> " + line + "\n"); + inputs.offer(line); + }); + + frame.setLayout(new BorderLayout()); + frame.add(new JScrollPane(output), BorderLayout.CENTER); + frame.add(input, BorderLayout.SOUTH); + frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + frame.setSize(800, 600); + frame.setLocationRelativeTo(null); + frame.setVisible(true); + input.requestFocusInWindow(); + } + + @Override + public String readLine() { + try { + return inputs.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ""; + } + } + + @Override + public void write(String s) { + SwingUtilities.invokeLater(() -> output.append(s + "\n")); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java new file mode 100644 index 0000000..1703c45 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ItemFactory.java @@ -0,0 +1,52 @@ +package thb.jeanluc.adventure.loader; + +import thb.jeanluc.adventure.loader.dto.ItemDto; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; +import thb.jeanluc.adventure.model.item.ReadableItem; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +/** + * Converts {@link ItemDto} instances coming from YAML into concrete + * {@link Item} subclasses. The {@code type} discriminator picks the + * subclass; unknown types fail fast with {@link WorldLoadException}. + */ +public final class ItemFactory { + + private ItemFactory() { + } + + /** + * Builds the appropriate {@link Item} subclass for the given DTO. + * + * @param dto data read from {@code items.yaml}; must not be null + * @return the freshly built item + * @throws WorldLoadException if {@code dto.type} is missing or unknown + */ + public static Item fromDto(ItemDto dto) { + String type = dto.type() == null ? "plain" : dto.type().toLowerCase(); + return switch (type) { + case "plain" -> PlainItem.builder() + .id(dto.id()) + .name(dto.name()) + .description(dto.description()) + .build(); + case "readable" -> ReadableItem.builder() + .id(dto.id()) + .name(dto.name()) + .description(dto.description()) + .readText(dto.readText()) + .build(); + case "switchable" -> SwitchableItem.builder() + .id(dto.id()) + .name(dto.name()) + .description(dto.description()) + .state(Boolean.TRUE.equals(dto.initialState())) + .onText(dto.onText()) + .offText(dto.offText()) + .build(); + default -> throw new WorldLoadException( + "Unknown item type '" + dto.type() + "' on item '" + dto.id() + "'"); + }; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/NpcFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/NpcFactory.java new file mode 100644 index 0000000..663267e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/NpcFactory.java @@ -0,0 +1,24 @@ +package thb.jeanluc.adventure.loader; + +import thb.jeanluc.adventure.loader.dto.NpcDto; +import thb.jeanluc.adventure.model.Npc; + +/** + * Builds an NPC shell (without resolved reactions) from a DTO. Reactions + * are wired in a later phase by {@link ReferenceResolver}. + */ +public final class NpcFactory { + + private NpcFactory() { + } + + /** + * Builds an NPC carrying an empty reactions map. + * + * @param dto data read from {@code npcs.yaml}; must not be null + * @return the freshly built NPC shell + */ + public static Npc shellFromDto(NpcDto dto) { + return Npc.shell(dto.id(), dto.name(), dto.description(), dto.greeting()); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java new file mode 100644 index 0000000..de1849d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/ReferenceResolver.java @@ -0,0 +1,131 @@ +package thb.jeanluc.adventure.loader; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.loader.dto.NpcDto; +import thb.jeanluc.adventure.loader.dto.ReactionDto; +import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.NpcReaction; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.List; +import java.util.Map; + +/** + * Phase 3 of the loading pipeline. Walks DTOs once more and resolves all + * string ids to actual domain references on the prebuilt registries. + */ +@RequiredArgsConstructor +public class ReferenceResolver { + + /** Global item registry, prebuilt by phase 2. */ + private final Map items; + + /** Global NPC registry, prebuilt by phase 2. */ + private final Map npcs; + + /** Global room registry, prebuilt by phase 2. */ + private final Map rooms; + + /** + * Wires exits, items, and NPCs into every room based on its DTO. + * + * @param roomDtos all room DTOs as read from YAML + * @throws WorldLoadException on any unresolved id or unparsable direction + */ + public void resolveRooms(List roomDtos) { + for (RoomDto dto : roomDtos) { + Room room = rooms.get(dto.id()); + if (dto.exits() != null) { + for (Map.Entry e : dto.exits().entrySet()) { + Direction direction; + try { + direction = Direction.fromString(e.getKey()); + } catch (IllegalArgumentException ex) { + throw new WorldLoadException( + "Room '" + dto.id() + "' has unknown exit direction '" + e.getKey() + "'"); + } + Room target = rooms.get(e.getValue()); + if (target == null) { + throw new WorldLoadException( + "Room '" + dto.id() + "' exits to unknown room '" + e.getValue() + "'"); + } + room.addExit(direction, target); + } + } + if (dto.items() != null) { + for (String itemId : dto.items()) { + Item item = items.get(itemId); + if (item == null) { + throw new WorldLoadException( + "Room '" + dto.id() + "' references unknown item '" + itemId + "'"); + } + room.addItem(item); + } + } + if (dto.npcs() != null) { + for (String npcId : dto.npcs()) { + Npc npc = npcs.get(npcId); + if (npc == null) { + throw new WorldLoadException( + "Room '" + dto.id() + "' references unknown npc '" + npcId + "'"); + } + room.addNpc(npc); + } + } + } + } + + /** + * Wires every NPC's reactions to actual item references. + * + * @param npcDtos all NPC DTOs as read from YAML + * @throws WorldLoadException on any unresolved id or duplicate trigger + */ + public void resolveNpcs(List npcDtos) { + for (NpcDto dto : npcDtos) { + if (dto.reactions() == null) { + continue; + } + Npc npc = npcs.get(dto.id()); + for (ReactionDto r : dto.reactions()) { + if (r.onReceive() == null) { + throw new WorldLoadException( + "NPC '" + dto.id() + "' has a reaction without onReceive"); + } + if (!items.containsKey(r.onReceive())) { + throw new WorldLoadException( + "NPC '" + dto.id() + "' reacts to unknown item '" + r.onReceive() + "'"); + } + if (npc.getReactions().containsKey(r.onReceive())) { + throw new WorldLoadException( + "NPC '" + dto.id() + "' has duplicate reaction for item '" + r.onReceive() + "'"); + } + Item gives = null; + if (r.gives() != null) { + gives = items.get(r.gives()); + if (gives == null) { + throw new WorldLoadException( + "NPC '" + dto.id() + "' gives unknown item '" + r.gives() + "'"); + } + } + Item consumes = null; + if (r.consumes() != null) { + consumes = items.get(r.consumes()); + if (consumes == null) { + throw new WorldLoadException( + "NPC '" + dto.id() + "' consumes unknown item '" + r.consumes() + "'"); + } + } + NpcReaction reaction = NpcReaction.builder() + .consumes(consumes) + .gives(gives) + .response(r.response()) + .build(); + npc.putReaction(r.onReceive(), reaction); + } + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java new file mode 100644 index 0000000..caf93f4 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java @@ -0,0 +1,24 @@ +package thb.jeanluc.adventure.loader; + +import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Room; + +/** + * Builds an empty {@link Room} shell from a DTO. Exits, items, and NPCs + * are wired in a later phase by {@link ReferenceResolver}. + */ +public final class RoomFactory { + + private RoomFactory() { + } + + /** + * Builds a fresh room with no exits, items, or NPCs. + * + * @param dto data read from {@code rooms.yaml}; must not be null + * @return the freshly built room shell + */ + public static Room shellFromDto(RoomDto dto) { + return new Room(dto.id(), dto.name(), dto.description()); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoadException.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoadException.java new file mode 100644 index 0000000..123514e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoadException.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.loader; + +/** + * Thrown when world loading fails: missing YAML files, malformed data, + * dangling references, or invariant violations. + */ +public class WorldLoadException extends RuntimeException { + + /** + * Creates an exception with a descriptive message. + * + * @param message human-readable explanation + */ + public WorldLoadException(String message) { + super(message); + } + + /** + * Creates an exception wrapping a lower-level cause. + * + * @param message human-readable explanation + * @param cause underlying cause + */ + public WorldLoadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java new file mode 100644 index 0000000..b6c8ee3 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java @@ -0,0 +1,142 @@ +package thb.jeanluc.adventure.loader; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.type.CollectionType; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import thb.jeanluc.adventure.loader.dto.GameDto; +import thb.jeanluc.adventure.loader.dto.ItemDto; +import thb.jeanluc.adventure.loader.dto.NpcDto; +import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.World; +import thb.jeanluc.adventure.model.item.Item; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Orchestrates the five-phase load (see {@code loading-flow.md}). Reads + * four YAML files from the classpath, builds DTOs, then domain shells, + * resolves references, validates, and finally returns the assembled + * {@link World} plus the freshly placed {@link Player}. + */ +@Slf4j +@RequiredArgsConstructor +public class WorldLoader { + + /** Default classpath base directory for the YAML files. */ + public static final String DEFAULT_BASE = "/world"; + + /** Single Jackson mapper, configured once for YAML input. */ + private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory()); + + /** Classpath base directory (typically {@link #DEFAULT_BASE} or a test override). */ + private final String basePath; + + /** + * Convenience constructor using {@link #DEFAULT_BASE}. + */ + public WorldLoader() { + this(DEFAULT_BASE); + } + + /** + * Result of a successful load: world plus the placed player. + * + * @param world the built world + * @param player the player placed at {@code game.yaml.startRoom} + */ + public record LoadResult(World world, Player player) { + } + + /** + * Loads the world by walking all five phases. + * + * @return the world and a placed player + * @throws WorldLoadException on any phase failure + */ + public LoadResult load() { + log.info("Loading world from classpath base '{}'", basePath); + + List itemDtos = readList(basePath + "/items.yaml", ItemDto.class); + List npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class); + List roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class); + GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class); + + requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList()); + requireUniqueIds("npc", npcDtos.stream().map(NpcDto::id).toList()); + requireUniqueIds("room", roomDtos.stream().map(RoomDto::id).toList()); + + Map items = new HashMap<>(); + for (ItemDto dto : itemDtos) { + items.put(dto.id(), ItemFactory.fromDto(dto)); + } + Map npcs = new HashMap<>(); + for (NpcDto dto : npcDtos) { + npcs.put(dto.id(), NpcFactory.shellFromDto(dto)); + } + Map rooms = new HashMap<>(); + for (RoomDto dto : roomDtos) { + rooms.put(dto.id(), RoomFactory.shellFromDto(dto)); + } + + ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms); + resolver.resolveRooms(roomDtos); + resolver.resolveNpcs(npcDtos); + + new WorldValidator(items, npcs, rooms, gameDto).validate(); + + Room start = rooms.get(gameDto.startRoom()); + int gold = gameDto.startGold() == null ? 0 : gameDto.startGold(); + Player player = new Player(start, gold); + + World world = new World(rooms, items, npcs, + gameDto.title(), gameDto.welcomeMessage()); + log.info("World '{}' loaded: {} rooms, {} items, {} npcs", + gameDto.title(), rooms.size(), items.size(), npcs.size()); + return new LoadResult(world, player); + } + + private List readList(String resource, Class elementType) { + try (InputStream in = openResource(resource)) { + CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType); + List result = yaml.readValue(in, type); + return result == null ? List.of() : result; + } catch (IOException e) { + throw new WorldLoadException("Failed to parse " + resource, e); + } + } + + private T readSingle(String resource, Class type) { + try (InputStream in = openResource(resource)) { + return yaml.readValue(in, type); + } catch (IOException e) { + throw new WorldLoadException("Failed to parse " + resource, e); + } + } + + private InputStream openResource(String resource) { + InputStream in = getClass().getResourceAsStream(resource); + if (in == null) { + throw new WorldLoadException("Resource not found on classpath: " + resource); + } + return in; + } + + private void requireUniqueIds(String kind, List ids) { + for (int i = 0; i < ids.size(); i++) { + for (int j = i + 1; j < ids.size(); j++) { + if (ids.get(i) != null && ids.get(i).equals(ids.get(j))) { + throw new WorldLoadException("Duplicate " + kind + " id: '" + ids.get(i) + "'"); + } + } + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldValidator.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldValidator.java new file mode 100644 index 0000000..5408924 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/WorldValidator.java @@ -0,0 +1,74 @@ +package thb.jeanluc.adventure.loader; + +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.loader.dto.GameDto; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Phase 4 of the loading pipeline. Walks the fully wired world and + * checks the invariants documented in {@code yaml-schemas.md}. + */ +@RequiredArgsConstructor +public class WorldValidator { + + /** Regex every entity id must match. */ + private static final Pattern ID_PATTERN = Pattern.compile("^[a-z][a-z0-9_]*$"); + + /** Item registry to validate. */ + private final Map items; + + /** NPC registry to validate. */ + private final Map npcs; + + /** Room registry to validate. */ + private final Map rooms; + + /** Loaded game.yaml whose {@code startRoom} must exist. */ + private final GameDto gameDto; + + /** + * Runs every validation rule. + * + * @throws WorldLoadException on the first failure + */ + public void validate() { + validateIds(); + validateStartRoom(); + validateGreetings(); + } + + private void validateIds() { + items.keySet().forEach(this::requireValidId); + npcs.keySet().forEach(this::requireValidId); + rooms.keySet().forEach(this::requireValidId); + } + + private void requireValidId(String id) { + if (id == null || !ID_PATTERN.matcher(id).matches()) { + throw new WorldLoadException("Invalid id '" + id + "': must match " + ID_PATTERN); + } + } + + private void validateStartRoom() { + if (gameDto == null || gameDto.startRoom() == null) { + throw new WorldLoadException("game.yaml is missing 'startRoom'"); + } + if (!rooms.containsKey(gameDto.startRoom())) { + throw new WorldLoadException( + "game.yaml.startRoom '" + gameDto.startRoom() + "' does not match any room"); + } + } + + private void validateGreetings() { + for (Npc npc : npcs.values()) { + if (npc.getGreeting() == null || npc.getGreeting().isBlank()) { + throw new WorldLoadException("NPC '" + npc.getId() + "' has an empty greeting"); + } + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/GameDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/GameDto.java new file mode 100644 index 0000000..8b704b2 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/GameDto.java @@ -0,0 +1,20 @@ +package thb.jeanluc.adventure.loader.dto; + +/** + * YAML representation of {@code game.yaml}. Plain data only; + * IDs are not yet resolved to domain references. + * + * @param title display title + * @param version free-form version label + * @param startRoom id of the room the player starts in + * @param startGold initial gold; defaults to 0 if null + * @param welcomeMessage shown to the player at startup + */ +public record GameDto( + String title, + String version, + String startRoom, + Integer startGold, + String welcomeMessage +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java new file mode 100644 index 0000000..8139f8c --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ItemDto.java @@ -0,0 +1,26 @@ +package thb.jeanluc.adventure.loader.dto; + +/** + * YAML representation of a single item. Fields outside of the type's + * scope (e.g. {@code readText} on a switchable) are simply ignored. + * + * @param type discriminator: {@code plain | readable | switchable} + * @param id unique slug + * @param name display name + * @param description examine description + * @param readText text printed when a readable item is used + * @param initialState initial on/off state of a switchable item + * @param onText message printed when a switchable transitions to on + * @param offText message printed when a switchable transitions to off + */ +public record ItemDto( + String type, + String id, + String name, + String description, + String readText, + Boolean initialState, + String onText, + String offText +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java new file mode 100644 index 0000000..23afcb1 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/NpcDto.java @@ -0,0 +1,21 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; + +/** + * YAML representation of a single NPC. + * + * @param id unique slug + * @param name display name + * @param description examine description + * @param greeting text the NPC says on {@code talk} + * @param reactions list of trigger/response definitions; may be null + */ +public record NpcDto( + String id, + String name, + String description, + String greeting, + List reactions +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java new file mode 100644 index 0000000..be1266a --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/ReactionDto.java @@ -0,0 +1,17 @@ +package thb.jeanluc.adventure.loader.dto; + +/** + * YAML representation of a single NPC reaction. + * + * @param onReceive id of the item that triggers this reaction + * @param response text the NPC says after the exchange + * @param gives id of the item handed back; nullable + * @param consumes id of the item taken from the player; nullable + */ +public record ReactionDto( + String onReceive, + String response, + String gives, + String consumes +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java new file mode 100644 index 0000000..44237d5 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java @@ -0,0 +1,24 @@ +package thb.jeanluc.adventure.loader.dto; + +import java.util.List; +import java.util.Map; + +/** + * YAML representation of a single room. + * + * @param id unique slug + * @param name display name + * @param description description shown by {@code look} + * @param exits direction-name to target-room-id map + * @param items ids of items initially in this room + * @param npcs ids of NPCs initially in this room + */ +public record RoomDto( + String id, + String name, + String description, + Map exits, + List items, + List npcs +) { +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Direction.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Direction.java new file mode 100644 index 0000000..d03d703 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Direction.java @@ -0,0 +1,58 @@ +package thb.jeanluc.adventure.model; + +/** + * Compass directions used for navigating between rooms. + * Each direction has an opposite, which is used when describing + * how the player enters the next room (e.g. moving NORTH means + * entering the next room from the SOUTH). + */ +public enum Direction { + NORTH, + SOUTH, + EAST, + WEST; + + /** + * Returns the opposite of this direction. + * + * @return the inverse direction + */ + public Direction getOpposite() { + return switch (this) { + case NORTH -> SOUTH; + case SOUTH -> NORTH; + case EAST -> WEST; + case WEST -> EAST; + }; + } + + /** + * Parses a direction from a case-insensitive string. Used when + * loading exits from YAML and when interpreting player input. + * + * @param s the direction name; surrounding whitespace is trimmed + * @return the matching Direction + * @throws IllegalArgumentException if s is null or does not match a known direction + */ + public static Direction fromString(String s) { + if (s == null) { + throw new IllegalArgumentException("Direction must not be null"); + } + try { + return Direction.valueOf(s.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Unknown direction: '" + s + "'"); + } + } + + /** + * Returns the human-readable lowercase label, suitable for display + * to the player (e.g. "north"). Intentionally not a toString override + * so that debug logs keep the enum constant form. + * + * @return lowercase direction name + */ + public String getLabel() { + return name().toLowerCase(); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java new file mode 100644 index 0000000..f6e9435 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Npc.java @@ -0,0 +1,77 @@ +package thb.jeanluc.adventure.model; + +import lombok.Builder; +import lombok.Getter; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Non-player character. Talks via a fixed {@link #greeting} and may react + * to items given to it through entries in {@link #reactions}. + */ +@Getter +@Builder +public class Npc { + + /** Unique identifier of this NPC, used for lookup and player input matching. */ + private final String id; + + /** Human-readable display name shown to the player. */ + private final String name; + + /** Long description shown by the {@code examine} command. */ + private final String description; + + /** Text the NPC says when talked to. */ + private final String greeting; + + /** + * Reactions keyed by the trigger item's id. {@code HashMap} gives O(1) + * lookup on {@code give X to Y}. The loader installs entries during + * reference resolution after all items have been built. + */ + private final Map reactions; + + /** + * Looks up a reaction triggered by an item. + * + * @param itemId id of the item being offered + * @return the matching reaction, or empty if the NPC does not react + */ + public Optional reactionFor(String itemId) { + return Optional.ofNullable(reactions.get(itemId)); + } + + /** + * Convenience factory used by the loader before reactions are resolved. + * Returns an NPC carrying a fresh, mutable reactions map. + * + * @param id unique id + * @param name display name + * @param description long description + * @param greeting greeting text + * @return a new NPC shell + */ + public static Npc shell(String id, String name, String description, String greeting) { + return Npc.builder() + .id(id) + .name(name) + .description(description) + .greeting(greeting) + .reactions(new HashMap<>()) + .build(); + } + + /** + * Adds or replaces a reaction. Used by the loader during reference + * resolution. + * + * @param triggerItemId id of the item that triggers the reaction + * @param reaction the reaction to attach + */ + public void putReaction(String triggerItemId, NpcReaction reaction) { + reactions.put(triggerItemId, reaction); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java new file mode 100644 index 0000000..8751327 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/NpcReaction.java @@ -0,0 +1,24 @@ +package thb.jeanluc.adventure.model; + +import lombok.Builder; +import lombok.Getter; +import thb.jeanluc.adventure.model.item.Item; + +/** + * A single trigger/response pair on an NPC. When the player gives the + * NPC an item matching {@link #consumes}, the NPC writes {@link #response} + * and optionally hands back {@link #gives}. + */ +@Getter +@Builder +public class NpcReaction { + + /** Item taken from the player's inventory; may be {@code null} if nothing is consumed. */ + private final Item consumes; + + /** Item handed back to the player; may be {@code null} if nothing is given. */ + private final Item gives; + + /** Text the NPC says after a successful exchange. */ + private final String response; +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java new file mode 100644 index 0000000..9bb0336 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java @@ -0,0 +1,87 @@ +package thb.jeanluc.adventure.model; + +import lombok.Getter; +import lombok.Setter; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.LinkedHashMap; +import java.util.Optional; + +/** + * The player avatar. Tracks the current room, an ordered inventory, and a + * gold counter. Mutated by the various command implementations. + */ +@Getter +public class Player { + + /** Room the player is currently standing in. */ + @Setter + private Room currentRoom; + + /** + * Inventory keyed by item id. {@link LinkedHashMap} keeps insertion + * order for stable {@code inventory} listings while still providing + * O(1) lookup on {@code drop}/{@code use}. + */ + private final LinkedHashMap inventory = new LinkedHashMap<>(); + + /** Gold currency. Currently informational; no commands spend it yet. */ + @Setter + private int gold; + + /** + * Creates a player at the given starting room with the given starting gold. + * + * @param startRoom room the player starts in; must not be null + * @param startGold initial gold amount; must be non-negative + */ + public Player(Room startRoom, int startGold) { + if (startRoom == null) { + throw new IllegalArgumentException("startRoom must not be null"); + } + if (startGold < 0) { + throw new IllegalArgumentException("startGold must not be negative"); + } + this.currentRoom = startRoom; + this.gold = startGold; + } + + /** + * Places an item into the inventory. + * + * @param item the item to add + */ + public void addItem(Item item) { + inventory.put(item.getId(), item); + } + + /** + * Removes and returns an item from the inventory. + * + * @param itemId id of the item to remove + * @return the removed item, or empty if it was not held + */ + public Optional removeItem(String itemId) { + return Optional.ofNullable(inventory.remove(itemId)); + } + + /** + * Looks up an inventory item without removing it. + * + * @param itemId id of the item to find + * @return the item, or empty if it is not in the inventory + */ + public Optional findItem(String itemId) { + return Optional.ofNullable(inventory.get(itemId)); + } + + /** + * Convenience predicate for inventory containment. + * + * @param itemId id of the item to test + * @return {@code true} if the player holds the item + */ + public boolean hasItem(String itemId) { + return inventory.containsKey(itemId); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java new file mode 100644 index 0000000..f104fed --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Room.java @@ -0,0 +1,114 @@ +package thb.jeanluc.adventure.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.Optional; + +/** + * A location in the game world. Rooms are connected to neighbouring rooms + * through {@link Direction}-keyed exits, and may contain items and NPCs + * that the player can interact with. + *

+ * Identity, name, and description are fixed after construction; the + * exits, items, and NPCs collections are mutable to support the player + * picking up items, dropping them, and the world otherwise evolving. + *

+ * Mutation should go through the convenience methods on this class so + * that ids stay consistent with the contained objects. + */ +@Getter +@RequiredArgsConstructor +public class Room { + + /** Unique identifier of this room within the world. */ + private final String id; + + /** Human-readable display name shown to the player. */ + private final String name; + + /** Description shown when the player enters the room or types {@code look}. */ + private final String description; + + /** Outgoing exits from this room, keyed by direction. */ + private final EnumMap exits = new EnumMap<>(Direction.class); + + /** Items currently in this room, keyed by item id. Insertion-ordered for stable display. */ + private final LinkedHashMap items = new LinkedHashMap<>(); + + /** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */ + private final LinkedHashMap npcs = new LinkedHashMap<>(); + + /** + * Connects this room to another in the given direction. Does not + * create the reverse connection — callers must set that up + * explicitly if a bidirectional path is desired. + * + * @param direction the direction in which the neighbour lies + * @param neighbour the room reachable from here in that direction + */ + public void addExit(Direction direction, Room neighbour) { + exits.put(direction, neighbour); + } + + /** + * Looks up the room reachable in the given direction. + * + * @param direction the direction to query + * @return the connected room, or empty if there is no exit that way + */ + public Optional getExit(Direction direction) { + return Optional.ofNullable(exits.get(direction)); + } + + /** + * Places an item in this room. + * + * @param item the item to add; its id is used as the map key + */ + public void addItem(Item item) { + items.put(item.getId(), item); + } + + /** + * Removes an item from this room by id. + * + * @param itemId id of the item to remove + * @return the removed item, or empty if no such item was present + */ + public Optional removeItem(String itemId) { + return Optional.ofNullable(items.remove(itemId)); + } + + /** + * Looks up an item in this room without removing it. + * + * @param itemId id of the item to find + * @return the item, or empty if no such item is present + */ + public Optional findItem(String itemId) { + return Optional.ofNullable(items.get(itemId)); + } + + /** + * Places an NPC in this room. + * + * @param npc the NPC to add; its id is used as the map key + */ + public void addNpc(Npc npc) { + npcs.put(npc.getId(), npc); + } + + /** + * Looks up an NPC in this room. + * + * @param npcId id of the NPC to find + * @return the NPC, or empty if no such NPC is present + */ + public Optional findNpc(String npcId) { + return Optional.ofNullable(npcs.get(npcId)); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java new file mode 100644 index 0000000..d2fb395 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/World.java @@ -0,0 +1,32 @@ +package thb.jeanluc.adventure.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import thb.jeanluc.adventure.model.item.Item; + +import java.util.Map; + +/** + * Aggregate root holding every entity in the game world plus global + * metadata read from {@code game.yaml}. Produced by the + * {@code WorldLoader} and handed to the game loop. + */ +@Getter +@RequiredArgsConstructor +public class World { + + /** Global lookup of rooms by id. */ + private final Map rooms; + + /** Global lookup of item prototypes by id. */ + private final Map items; + + /** Global lookup of NPCs by id. */ + private final Map npcs; + + /** Display title shown to the player at startup. */ + private final String title; + + /** Welcome message printed once when the game starts. */ + private final String welcomeMessage; +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java new file mode 100644 index 0000000..504d805 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/Item.java @@ -0,0 +1,43 @@ +package thb.jeanluc.adventure.model.item; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.SuperBuilder; +import thb.jeanluc.adventure.game.GameContext; + +/** + * Abstract base class for every interactive object in the world. + * Concrete subclasses implement {@link #use(GameContext)} to provide + * type-specific behaviour. Items are intentionally "dumb": they do not + * know which room or inventory currently holds them. + */ +@Getter +@SuperBuilder +@RequiredArgsConstructor +@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 { + + /** Unique identifier of this item, used for lookup and player input matching. */ + protected final String id; + + /** Human-readable display name shown to the player. */ + protected final String name; + + /** Long description shown by the {@code examine} command. */ + protected final String description; + + /** + * Executes the item's primary action. Side effects (text output, + * mutation of player state) flow through the given context. + * + * @param ctx active game context providing player, world, and IO + */ + public abstract void use(GameContext ctx); +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/PlainItem.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/PlainItem.java new file mode 100644 index 0000000..ee15e9f --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/PlainItem.java @@ -0,0 +1,23 @@ +package thb.jeanluc.adventure.model.item; + +import lombok.experimental.SuperBuilder; +import thb.jeanluc.adventure.game.GameContext; + +/** + * Item with no inherent behaviour. Exists so that every item in the + * world can polymorphically answer {@link #use(GameContext)} and so the + * YAML schema can rely on a uniform {@code type} discriminator. + */ +@SuperBuilder +public class PlainItem extends Item { + + /** + * Writes a generic message that this item cannot be used on its own. + * + * @param ctx active game context + */ + @Override + public void use(GameContext ctx) { + ctx.getIo().write("You can't use the " + name + " by itself."); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/ReadableItem.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/ReadableItem.java new file mode 100644 index 0000000..84fcf5b --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/ReadableItem.java @@ -0,0 +1,27 @@ +package thb.jeanluc.adventure.model.item; + +import lombok.Getter; +import lombok.experimental.SuperBuilder; +import thb.jeanluc.adventure.game.GameContext; + +/** + * Item that displays a piece of text when used or read. Typical examples + * include letters, signs, and books. + */ +@Getter +@SuperBuilder +public class ReadableItem extends Item { + + /** Text written on the item; printed to the player on {@code use}/{@code read}. */ + private final String readText; + + /** + * Writes the item's text to the player's output. + * + * @param ctx active game context + */ + @Override + public void use(GameContext ctx) { + ctx.getIo().write(readText); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java new file mode 100644 index 0000000..e3b7684 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/item/SwitchableItem.java @@ -0,0 +1,46 @@ +package thb.jeanluc.adventure.model.item; + +import lombok.Getter; +import lombok.experimental.SuperBuilder; +import thb.jeanluc.adventure.game.GameContext; + +/** + * Item with two boolean states (on / off). Each {@code use} toggles the + * state and prints the corresponding transition message. + */ +@Getter +@SuperBuilder +public class SwitchableItem extends Item { + + /** Text written when the item transitions to on. */ + private final String onText; + + /** Text written when the item transitions to off. */ + private final String offText; + + /** + * Current on/off state. Seeded by the builder from the YAML + * {@code initialState} field via the item factory. + */ + private boolean state; + + /** + * Toggles the state and writes the corresponding transition text. + * + * @param ctx active game context + */ + @Override + public void use(GameContext ctx) { + state = !state; + ctx.getIo().write(state ? onText : offText); + } + + /** + * Convenience predicate for the current state. + * + * @return {@code true} if the item is currently on + */ + public boolean isOn() { + return state; + } +} diff --git a/Semesterprojekt/src/main/resources/logback.xml b/Semesterprojekt/src/main/resources/logback.xml new file mode 100644 index 0000000..1d635ce --- /dev/null +++ b/Semesterprojekt/src/main/resources/logback.xml @@ -0,0 +1,12 @@ + + + System.err + + %d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n + + + + + + + diff --git a/Semesterprojekt/src/main/resources/world/game.yaml b/Semesterprojekt/src/main/resources/world/game.yaml new file mode 100644 index 0000000..f38d152 --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/game.yaml @@ -0,0 +1,7 @@ +title: Haunted Manor +version: "1.0" +startRoom: kitchen +startGold: 0 +welcomeMessage: | + Welcome to the Haunted Manor. + Type 'help' to see available commands. diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml new file mode 100644 index 0000000..06d0650 --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/items.yaml @@ -0,0 +1,24 @@ +- type: readable + id: letter + name: Letter + description: A crumpled piece of paper. + readText: | + "Meet me at midnight in the cellar. - A." + +- type: switchable + id: lamp + name: Oil Lamp + description: An old oil lamp, heavy with fuel. + initialState: false + onText: The lamp flares to life, casting a warm glow. + offText: You snuff out the lamp. + +- type: plain + id: shovel + name: Shovel + description: A rusty shovel. Sturdy enough to dig. + +- type: plain + id: key + name: Brass Key + description: A small brass key, polished from use. diff --git a/Semesterprojekt/src/main/resources/world/npcs.yaml b/Semesterprojekt/src/main/resources/world/npcs.yaml new file mode 100644 index 0000000..5d9b04c --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/npcs.yaml @@ -0,0 +1,12 @@ +- id: old_man + name: Old Man + description: A stooped old man with a grey beard. + greeting: | + "Hello, traveller. If you bring me the oil lamp, + I will give you the key to the cellar." + reactions: + - onReceive: lamp + response: | + "Thank you! Here, take this key." + gives: key + consumes: lamp diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml new file mode 100644 index 0000000..8dccb7d --- /dev/null +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -0,0 +1,41 @@ +- id: kitchen + name: Old Kitchen + description: | + A dusty kitchen. Cobwebs hang from the rafters + and a letter lies on the table. + 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. + Faint footsteps echo somewhere far away. + exits: + south: kitchen + west: library + items: [] + npcs: [] + +- id: library + name: Library + description: | + Tall shelves crammed with mouldering books. + An old chest stands in the corner. + exits: + east: hallway + items: [shovel] + npcs: [] + +- id: cellar + name: Damp Cellar + description: | + Cold, damp, and very dark. You can barely + make out shapes against the far wall. + exits: + west: kitchen + items: [] + npcs: [] diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/CommandParserTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/CommandParserTest.java new file mode 100644 index 0000000..1d7bd59 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/CommandParserTest.java @@ -0,0 +1,38 @@ +package thb.jeanluc.adventure.command; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandParserTest { + + private final CommandParser parser = new CommandParser(); + + @Test + void parse_blankInput_yieldsEmptyVerb() { + assertThat(parser.parse("").verb()).isEmpty(); + assertThat(parser.parse(" ").verb()).isEmpty(); + assertThat(parser.parse(null).verb()).isEmpty(); + } + + @Test + void parse_lowercases() { + ParsedCommand p = parser.parse("Go NORTH"); + assertThat(p.verb()).isEqualTo("go"); + assertThat(p.args()).containsExactly("north"); + } + + @Test + void parse_dropsFillers() { + ParsedCommand p = parser.parse("go to the north"); + assertThat(p.verb()).isEqualTo("go"); + assertThat(p.args()).containsExactly("north"); + } + + @Test + void parse_giveItemToNpc_keepsBoth() { + ParsedCommand p = parser.parse("give lamp to old_man"); + assertThat(p.verb()).isEqualTo("give"); + assertThat(p.args()).containsExactly("lamp", "old_man"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/CommandRegistryTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/CommandRegistryTest.java new file mode 100644 index 0000000..9c09530 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/CommandRegistryTest.java @@ -0,0 +1,43 @@ +package thb.jeanluc.adventure.command; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommandRegistryTest { + + private static class StubCommand implements Command { + @Override public void execute(GameContext ctx, List args) {} + @Override public String help() { return "stub"; } + } + + @Test + void register_andFindByEachAlias() { + CommandRegistry r = new CommandRegistry(); + StubCommand cmd = new StubCommand(); + r.register(cmd, "go", "move", "walk"); + + assertThat(r.find("go")).contains(cmd); + assertThat(r.find("MOVE")).contains(cmd); + assertThat(r.find("walk")).contains(cmd); + } + + @Test + void find_unknownVerb_returnsEmpty() { + assertThat(new CommandRegistry().find("nope")).isEmpty(); + } + + @Test + void distinctCommands_deduplicatesAliases() { + CommandRegistry r = new CommandRegistry(); + StubCommand a = new StubCommand(); + StubCommand b = new StubCommand(); + r.register(a, "x", "y"); + r.register(b, "z"); + + assertThat(r.distinctCommands()).containsExactlyInAnyOrder(a, b); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/CommandTestSupport.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/CommandTestSupport.java new file mode 100644 index 0000000..51c644c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/CommandTestSupport.java @@ -0,0 +1,60 @@ +package thb.jeanluc.adventure.command.impl; + +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.NpcReaction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; +import thb.jeanluc.adventure.model.item.ReadableItem; +import thb.jeanluc.adventure.model.item.SwitchableItem; + +/** + * Tiny world used by the command tests. Two rooms (kitchen → hallway), + * a few items, and one NPC with one reaction. + */ +public final class CommandTestSupport { + + private CommandTestSupport() {} + + public static class World { + public final Room kitchen; + public final Room hallway; + public final Item lamp; + public final Item letter; + public final Item key; + public final Item shovel; + public final Npc oldMan; + public final Player player; + public final TestIO io = new TestIO(); + public final GameContext ctx; + + public World() { + kitchen = new Room("kitchen", "Kitchen", "kitchen desc"); + hallway = new Room("hallway", "Hallway", "hallway desc"); + kitchen.addExit(Direction.NORTH, hallway); + hallway.addExit(Direction.SOUTH, kitchen); + + lamp = SwitchableItem.builder().id("lamp").name("Lamp").description("a lamp") + .state(false).onText("on").offText("off").build(); + letter = ReadableItem.builder().id("letter").name("Letter").description("a letter") + .readText("dear reader").build(); + key = PlainItem.builder().id("key").name("Key").description("brass key").build(); + shovel = PlainItem.builder().id("shovel").name("Shovel").description("rusty").build(); + + kitchen.addItem(lamp); + kitchen.addItem(letter); + + oldMan = Npc.shell("old_man", "Old Man", "stooped", "greetings"); + oldMan.putReaction("lamp", NpcReaction.builder() + .consumes(lamp).gives(key).response("Take the key.").build()); + kitchen.addNpc(oldMan); + + player = new Player(kitchen, 0); + ctx = new GameContext(null, player, io); + } + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/GoCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/GoCommandTest.java new file mode 100644 index 0000000..fde1059 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/GoCommandTest.java @@ -0,0 +1,48 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class GoCommandTest { + + @Test + void execute_validDirection_movesPlayer() { + CommandTestSupport.World w = new CommandTestSupport.World(); + + new GoCommand().execute(w.ctx, List.of("north")); + + assertThat(w.player.getCurrentRoom()).isEqualTo(w.hallway); + } + + @Test + void execute_noExit_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + + new GoCommand().execute(w.ctx, List.of("south")); + + assertThat(w.player.getCurrentRoom()).isEqualTo(w.kitchen); + assertThat(w.io.allOutput()).contains("can't go south"); + } + + @Test + void execute_unknownDirection_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + + new GoCommand().execute(w.ctx, List.of("upwards")); + + assertThat(w.player.getCurrentRoom()).isEqualTo(w.kitchen); + assertThat(w.io.allOutput()).contains("upwards"); + } + + @Test + void execute_noArg_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + + new GoCommand().execute(w.ctx, List.of()); + + assertThat(w.io.allOutput()).contains("Go where"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/InventoryCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/InventoryCommandTest.java new file mode 100644 index 0000000..599af6c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/InventoryCommandTest.java @@ -0,0 +1,28 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class InventoryCommandTest { + + @Test + void empty_writesEmptyMessage() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new InventoryCommand().execute(w.ctx, List.of()); + assertThat(w.io.lastOutput()).contains("not carrying"); + } + + @Test + void withItems_listsByName() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("letter")); + new TakeCommand().execute(w.ctx, List.of("lamp")); + + new InventoryCommand().execute(w.ctx, List.of()); + + assertThat(w.io.lastOutput()).contains("Letter").contains("Lamp"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LookCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LookCommandTest.java new file mode 100644 index 0000000..b7b7150 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/LookCommandTest.java @@ -0,0 +1,37 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class LookCommandTest { + + @Test + void look_writesNameDescriptionItemsNpcsAndExits() { + CommandTestSupport.World w = new CommandTestSupport.World(); + + new LookCommand().execute(w.ctx, List.of()); + + String text = w.io.lastOutput(); + assertThat(text).contains("Kitchen"); + assertThat(text).contains("kitchen desc"); + assertThat(text).contains("Lamp").contains("Letter"); + assertThat(text).contains("Old Man"); + assertThat(text).contains("north"); + } + + @Test + void look_noItemsNoNpcs_stillWorks() { + CommandTestSupport.World w = new CommandTestSupport.World(); + w.player.setCurrentRoom(w.hallway); + + new LookCommand().execute(w.ctx, List.of()); + + String text = w.io.lastOutput(); + assertThat(text).contains("Hallway"); + assertThat(text).contains("south"); + assertThat(text).doesNotContain("You see"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/TakeDropCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/TakeDropCommandTest.java new file mode 100644 index 0000000..f7df093 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/TakeDropCommandTest.java @@ -0,0 +1,45 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TakeDropCommandTest { + + @Test + void take_movesItemFromRoomToInventory() { + CommandTestSupport.World w = new CommandTestSupport.World(); + + new TakeCommand().execute(w.ctx, List.of("lamp")); + + assertThat(w.player.hasItem("lamp")).isTrue(); + assertThat(w.kitchen.findItem("lamp")).isEmpty(); + } + + @Test + void take_unknownItem_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("dragon")); + assertThat(w.io.allOutput()).contains("no 'dragon'"); + } + + @Test + void drop_movesItemFromInventoryToRoom() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("lamp")); + + new DropCommand().execute(w.ctx, List.of("lamp")); + + assertThat(w.player.hasItem("lamp")).isFalse(); + assertThat(w.kitchen.findItem("lamp")).isPresent(); + } + + @Test + void drop_notInInventory_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new DropCommand().execute(w.ctx, List.of("lamp")); + assertThat(w.io.allOutput()).contains("not carrying"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/TalkGiveCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/TalkGiveCommandTest.java new file mode 100644 index 0000000..4029042 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/TalkGiveCommandTest.java @@ -0,0 +1,64 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TalkGiveCommandTest { + + @Test + void talk_existingNpc_writesGreeting() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TalkCommand().execute(w.ctx, List.of("old_man")); + assertThat(w.io.lastOutput()).contains("Old Man").contains("greetings"); + } + + @Test + void talk_missingNpc_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TalkCommand().execute(w.ctx, List.of("ghost")); + assertThat(w.io.allOutput()).contains("no 'ghost'"); + } + + @Test + void give_matchingReaction_swapsItems() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("lamp")); + + new GiveCommand().execute(w.ctx, List.of("lamp", "old_man")); + + assertThat(w.player.hasItem("lamp")).isFalse(); + assertThat(w.player.hasItem("key")).isTrue(); + assertThat(w.io.lastOutput()).contains("Take the key"); + } + + @Test + void give_noReaction_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("letter")); + + new GiveCommand().execute(w.ctx, List.of("letter", "old_man")); + + assertThat(w.player.hasItem("letter")).isTrue(); + assertThat(w.io.lastOutput()).contains("does not react"); + } + + @Test + void give_unknownNpc_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("lamp")); + + new GiveCommand().execute(w.ctx, List.of("lamp", "ghost")); + + assertThat(w.io.allOutput()).contains("no 'ghost'"); + } + + @Test + void give_itemNotHeld_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new GiveCommand().execute(w.ctx, List.of("lamp", "old_man")); + assertThat(w.io.allOutput()).contains("not carrying"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/UseReadExamineCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/UseReadExamineCommandTest.java new file mode 100644 index 0000000..d6d62b2 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/UseReadExamineCommandTest.java @@ -0,0 +1,51 @@ +package thb.jeanluc.adventure.command.impl; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class UseReadExamineCommandTest { + + @Test + void use_readableInInventory_writesText() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("letter")); + + new UseCommand().execute(w.ctx, List.of("letter")); + + assertThat(w.io.lastOutput()).isEqualTo("dear reader"); + } + + @Test + void use_unknownItem_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new UseCommand().execute(w.ctx, List.of("dragon")); + assertThat(w.io.allOutput()).contains("no 'dragon'"); + } + + @Test + void read_nonReadable_complains() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new TakeCommand().execute(w.ctx, List.of("lamp")); + + new ReadCommand().execute(w.ctx, List.of("lamp")); + + assertThat(w.io.lastOutput()).contains("nothing to read"); + } + + @Test + void examine_item_writesDescription() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new ExamineCommand().execute(w.ctx, List.of("letter")); + assertThat(w.io.lastOutput()).isEqualTo("a letter"); + } + + @Test + void examine_npc_writesDescription() { + CommandTestSupport.World w = new CommandTestSupport.World(); + new ExamineCommand().execute(w.ctx, List.of("old_man")); + assertThat(w.io.lastOutput()).isEqualTo("stooped"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java new file mode 100644 index 0000000..2cd53a5 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java @@ -0,0 +1,58 @@ +package thb.jeanluc.adventure.game; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.command.CommandParser; +import thb.jeanluc.adventure.command.CommandRegistry; +import thb.jeanluc.adventure.command.impl.GoCommand; +import thb.jeanluc.adventure.command.impl.LookCommand; +import thb.jeanluc.adventure.command.impl.QuitCommand; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import static org.assertj.core.api.Assertions.assertThat; + +class GameTest { + + @Test + void run_dispatchesCommands_andQuitsCleanly() { + Room kitchen = new Room("kitchen", "Kitchen", "kd"); + Room hallway = new Room("hallway", "Hallway", "hd"); + kitchen.addExit(Direction.NORTH, hallway); + Player player = new Player(kitchen, 0); + TestIO io = new TestIO() + .enqueue("go north") + .enqueue("quit"); + GameContext ctx = new GameContext(null, player, io); + + CommandRegistry registry = new CommandRegistry(); + registry.register(new GoCommand(), "go"); + registry.register(new LookCommand(), "look"); + QuitCommand quit = new QuitCommand(); + registry.register(quit, "quit"); + + Game game = new Game(ctx, registry, new CommandParser()); + quit.bind(game); + game.run(); + + assertThat(player.getCurrentRoom()).isEqualTo(hallway); + assertThat(io.allOutput()).contains("Goodbye"); + } + + @Test + void run_unknownVerb_writesHint() { + Player player = new Player(new Room("r", "R", "d"), 0); + TestIO io = new TestIO().enqueue("dance").enqueue("quit"); + GameContext ctx = new GameContext(null, player, io); + + CommandRegistry registry = new CommandRegistry(); + QuitCommand quit = new QuitCommand(); + registry.register(quit, "quit"); + Game game = new Game(ctx, registry, new CommandParser()); + quit.bind(game); + game.run(); + + assertThat(io.allOutput()).contains("don't understand 'dance'"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java new file mode 100644 index 0000000..7a8bacd --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java @@ -0,0 +1,42 @@ +package thb.jeanluc.adventure.io; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/** + * Simple in-memory {@link GameIO} for tests: queued inputs, captured outputs. + */ +public class TestIO implements GameIO { + + private final Deque inputs = new ArrayDeque<>(); + private final List outputs = new ArrayList<>(); + + public TestIO enqueue(String line) { + inputs.add(line); + return this; + } + + public List outputs() { + return outputs; + } + + public String lastOutput() { + return outputs.isEmpty() ? null : outputs.getLast(); + } + + public String allOutput() { + return String.join("\n", outputs); + } + + @Override + public String readLine() { + return inputs.pollFirst(); + } + + @Override + public void write(String s) { + outputs.add(s); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/ReferenceResolverTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/ReferenceResolverTest.java new file mode 100644 index 0000000..21a7163 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/ReferenceResolverTest.java @@ -0,0 +1,120 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.dto.NpcDto; +import thb.jeanluc.adventure.loader.dto.ReactionDto; +import thb.jeanluc.adventure.loader.dto.RoomDto; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReferenceResolverTest { + + private static Map oneItem(String id) { + Map m = new HashMap<>(); + m.put(id, PlainItem.builder().id(id).name(id).description("d").build()); + return m; + } + + @Test + void resolveRooms_wiresExitsAndItems() { + Map rooms = new HashMap<>(); + rooms.put("a", new Room("a", "A", "d")); + rooms.put("b", new Room("b", "B", "d")); + Map items = oneItem("key"); + + new ReferenceResolver(items, new HashMap<>(), rooms).resolveRooms(List.of( + new RoomDto("a", "A", "d", Map.of("north", "b"), List.of("key"), List.of()), + new RoomDto("b", "B", "d", Map.of("south", "a"), List.of(), List.of()) + )); + + assertThat(rooms.get("a").getExit(Direction.NORTH)).contains(rooms.get("b")); + assertThat(rooms.get("a").findItem("key")).isPresent(); + } + + @Test + void resolveRooms_unknownExitTarget_throws() { + Map rooms = new HashMap<>(); + rooms.put("a", new Room("a", "A", "d")); + + assertThatThrownBy(() -> + new ReferenceResolver(Map.of(), Map.of(), rooms).resolveRooms(List.of( + new RoomDto("a", "A", "d", Map.of("north", "ghost"), List.of(), List.of()) + ))) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("ghost"); + } + + @Test + void resolveRooms_unknownDirection_throws() { + Map rooms = new HashMap<>(); + rooms.put("a", new Room("a", "A", "d")); + + assertThatThrownBy(() -> + new ReferenceResolver(Map.of(), Map.of(), rooms).resolveRooms(List.of( + new RoomDto("a", "A", "d", Map.of("up", "a"), List.of(), List.of()) + ))) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("up"); + } + + @Test + void resolveNpcs_wiresReactionItems() { + Map items = oneItem("lamp"); + items.put("key", PlainItem.builder().id("key").name("Key").description("d").build()); + Map npcs = new HashMap<>(); + npcs.put("man", Npc.shell("man", "Man", "d", "hi")); + + new ReferenceResolver(items, npcs, Map.of()).resolveNpcs(List.of( + new NpcDto("man", "Man", "d", "hi", List.of( + new ReactionDto("lamp", "thx", "key", "lamp") + )) + )); + + var reaction = npcs.get("man").reactionFor("lamp").orElseThrow(); + assertThat(reaction.getGives()).isEqualTo(items.get("key")); + assertThat(reaction.getConsumes()).isEqualTo(items.get("lamp")); + } + + @Test + void resolveNpcs_duplicateTrigger_throws() { + Map items = oneItem("lamp"); + Map npcs = new HashMap<>(); + npcs.put("man", Npc.shell("man", "Man", "d", "hi")); + + assertThatThrownBy(() -> + new ReferenceResolver(items, npcs, Map.of()).resolveNpcs(List.of( + new NpcDto("man", "Man", "d", "hi", List.of( + new ReactionDto("lamp", "a", null, null), + new ReactionDto("lamp", "b", null, null) + )) + ))) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("duplicate"); + } + + @Test + void resolveNpcs_unknownGivesItem_throws() { + Map items = oneItem("lamp"); + Map npcs = new HashMap<>(); + npcs.put("man", Npc.shell("man", "Man", "d", "hi")); + + assertThatThrownBy(() -> + new ReferenceResolver(items, npcs, Map.of()).resolveNpcs(List.of( + new NpcDto("man", "Man", "d", "hi", List.of( + new ReactionDto("lamp", "a", "ghost", null) + )) + ))) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("ghost"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java new file mode 100644 index 0000000..dc7055d --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java @@ -0,0 +1,44 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.NpcReaction; + +import static org.assertj.core.api.Assertions.assertThat; + +class WorldLoaderTest { + + @Test + void load_happyPath_buildsWorldFromTestFixtures() { + WorldLoader.LoadResult result = new WorldLoader().load(); + + assertThat(result.world().getTitle()).isEqualTo("Test Manor"); + assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway"); + assertThat(result.world().getItems().keySet()).containsExactlyInAnyOrder("letter", "lamp", "key"); + assertThat(result.world().getNpcs().keySet()).containsExactly("old_man"); + + assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen"); + assertThat(result.player().getGold()).isEqualTo(5); + } + + @Test + void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() { + var loaded = new WorldLoader().load().world(); + var kitchen = loaded.getRooms().get("kitchen"); + var hallway = loaded.getRooms().get("hallway"); + + assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway); + assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen); + } + + @Test + void load_resolvesNpcReactionsToItemReferences() { + var loaded = new WorldLoader().load().world(); + var oldMan = loaded.getNpcs().get("old_man"); + + assertThat(oldMan.getReactions()).containsKey("lamp"); + NpcReaction r = oldMan.reactionFor("lamp").orElseThrow(); + assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp")); + assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key")); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldValidatorTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldValidatorTest.java new file mode 100644 index 0000000..c7c4b33 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldValidatorTest.java @@ -0,0 +1,81 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.loader.dto.GameDto; +import thb.jeanluc.adventure.model.Npc; +import thb.jeanluc.adventure.model.Room; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class WorldValidatorTest { + + private Map items() { + Map m = new HashMap<>(); + m.put("lamp", PlainItem.builder().id("lamp").name("Lamp").description("d").build()); + return m; + } + + private Map rooms() { + Map m = new HashMap<>(); + m.put("kitchen", new Room("kitchen", "K", "d")); + return m; + } + + private Map npcs(String greeting) { + Map m = new HashMap<>(); + m.put("man", Npc.shell("man", "Man", "d", greeting)); + return m; + } + + @Test + void happyPath_doesNotThrow() { + var v = new WorldValidator(items(), npcs("hi"), rooms(), + new GameDto("t", "1", "kitchen", 0, "w")); + assertThat(v).isNotNull(); + v.validate(); + } + + @Test + void invalidId_throws() { + Map r = new HashMap<>(); + r.put("Kitchen-1", new Room("Kitchen-1", "K", "d")); + var v = new WorldValidator(items(), npcs("hi"), r, + new GameDto("t", "1", "kitchen", 0, "w")); + assertThatThrownBy(v::validate) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("Invalid id"); + } + + @Test + void unknownStartRoom_throws() { + var v = new WorldValidator(items(), npcs("hi"), rooms(), + new GameDto("t", "1", "ghost_room", 0, "w")); + assertThatThrownBy(v::validate) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("ghost_room"); + } + + @Test + void emptyGreeting_throws() { + var v = new WorldValidator(items(), npcs(""), rooms(), + new GameDto("t", "1", "kitchen", 0, "w")); + assertThatThrownBy(v::validate) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("greeting"); + } + + @Test + void missingStartRoomField_throws() { + var v = new WorldValidator(items(), npcs("hi"), rooms(), + new GameDto("t", "1", null, 0, "w")); + assertThatThrownBy(v::validate) + .isInstanceOf(WorldLoadException.class) + .hasMessageContaining("startRoom"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/DirectionTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/DirectionTest.java new file mode 100644 index 0000000..b342f7a --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/DirectionTest.java @@ -0,0 +1,41 @@ +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DirectionTest { + + @Test + void getOpposite_pairsAreSymmetric() { + for (Direction d : Direction.values()) { + assertThat(d.getOpposite().getOpposite()).isEqualTo(d); + } + } + + @Test + void fromString_caseInsensitive_returnsEnum() { + assertThat(Direction.fromString("north")).isEqualTo(Direction.NORTH); + assertThat(Direction.fromString("EAST")).isEqualTo(Direction.EAST); + assertThat(Direction.fromString(" west ")).isEqualTo(Direction.WEST); + } + + @Test + void fromString_unknown_throws() { + assertThatThrownBy(() -> Direction.fromString("up")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("up"); + } + + @Test + void fromString_null_throws() { + assertThatThrownBy(() -> Direction.fromString(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void getLabel_isLowercase() { + assertThat(Direction.NORTH.getLabel()).isEqualTo("north"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/NpcTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/NpcTest.java new file mode 100644 index 0000000..7fd5d69 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/NpcTest.java @@ -0,0 +1,22 @@ +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import static org.assertj.core.api.Assertions.assertThat; + +class NpcTest { + + @Test + void shell_isMutableForReactions() { + Npc man = Npc.shell("man", "Man", "d", "hi"); + Item lamp = PlainItem.builder().id("lamp").name("Lamp").description("d").build(); + + man.putReaction("lamp", NpcReaction.builder() + .consumes(lamp).response("thanks").build()); + + assertThat(man.reactionFor("lamp")).isPresent(); + assertThat(man.reactionFor("nope")).isEmpty(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java new file mode 100644 index 0000000..0b4fc6c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java @@ -0,0 +1,57 @@ +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PlayerTest { + + @Test + void constructor_nullRoom_throws() { + assertThatThrownBy(() -> new Player(null, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void constructor_negativeGold_throws() { + Room r = new Room("r", "R", "d"); + assertThatThrownBy(() -> new Player(r, -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void addItem_andRemoveItem_areConsistent() { + Player p = new Player(new Room("r", "R", "d"), 0); + Item it = PlainItem.builder().id("key").name("Key").description("d").build(); + + p.addItem(it); + assertThat(p.hasItem("key")).isTrue(); + + assertThat(p.removeItem("key")).contains(it); + assertThat(p.hasItem("key")).isFalse(); + } + + @Test + void inventory_preservesInsertionOrder() { + Player p = new Player(new Room("r", "R", "d"), 0); + p.addItem(PlainItem.builder().id("a").name("A").description("d").build()); + p.addItem(PlainItem.builder().id("b").name("B").description("d").build()); + p.addItem(PlainItem.builder().id("c").name("C").description("d").build()); + + assertThat(p.getInventory().keySet()).containsExactly("a", "b", "c"); + } + + @Test + void setCurrentRoom_updatesPosition() { + Room a = new Room("a", "A", "d"); + Room b = new Room("b", "B", "d"); + Player p = new Player(a, 0); + + p.setCurrentRoom(b); + + assertThat(p.getCurrentRoom()).isEqualTo(b); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/RoomTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/RoomTest.java new file mode 100644 index 0000000..c29267c --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/RoomTest.java @@ -0,0 +1,69 @@ +package thb.jeanluc.adventure.model; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.model.item.Item; +import thb.jeanluc.adventure.model.item.PlainItem; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoomTest { + + @Test + void addExit_withNewDirection_storesNeighbour() { + Room a = new Room("a", "A", "first"); + Room b = new Room("b", "B", "second"); + + a.addExit(Direction.NORTH, b); + + assertThat(a.getExit(Direction.NORTH)).contains(b); + assertThat(a.getExit(Direction.SOUTH)).isEmpty(); + } + + @Test + void addItem_storesUnderItemId() { + Room room = new Room("r", "R", "d"); + Item lamp = PlainItem.builder().id("lamp").name("Lamp").description("d").build(); + + room.addItem(lamp); + + assertThat(room.findItem("lamp")).contains(lamp); + } + + @Test + void removeItem_returnsAndDetachesItem() { + Room room = new Room("r", "R", "d"); + Item lamp = PlainItem.builder().id("lamp").name("Lamp").description("d").build(); + room.addItem(lamp); + + var removed = room.removeItem("lamp"); + + assertThat(removed).contains(lamp); + assertThat(room.findItem("lamp")).isEmpty(); + } + + @Test + void removeItem_unknownId_returnsEmpty() { + Room room = new Room("r", "R", "d"); + assertThat(room.removeItem("nope")).isEmpty(); + } + + @Test + void addNpc_andFindById() { + Room room = new Room("r", "R", "d"); + Npc npc = Npc.shell("man", "Man", "d", "hi"); + + room.addNpc(npc); + + assertThat(room.findNpc("man")).contains(npc); + } + + @Test + void items_preserveInsertionOrder() { + Room room = new Room("r", "R", "d"); + room.addItem(PlainItem.builder().id("a").name("A").description("d").build()); + room.addItem(PlainItem.builder().id("b").name("B").description("d").build()); + room.addItem(PlainItem.builder().id("c").name("C").description("d").build()); + + assertThat(room.getItems().keySet()).containsExactly("a", "b", "c"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/ItemTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/ItemTest.java new file mode 100644 index 0000000..257bdaa --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/item/ItemTest.java @@ -0,0 +1,66 @@ +package thb.jeanluc.adventure.model.item; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Player; +import thb.jeanluc.adventure.model.Room; + +import static org.assertj.core.api.Assertions.assertThat; + +class ItemTest { + + private GameContext newCtx(TestIO io) { + Player p = new Player(new Room("r", "R", "d"), 0); + return new GameContext(null, p, io); + } + + @Test + void readableItem_use_writesReadText() { + TestIO io = new TestIO(); + ReadableItem letter = ReadableItem.builder() + .id("letter").name("Letter").description("d") + .readText("hello world").build(); + + letter.use(newCtx(io)); + + assertThat(io.lastOutput()).isEqualTo("hello world"); + } + + @Test + void switchableItem_use_togglesAndPrintsAppropriateText() { + TestIO io = new TestIO(); + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d") + .state(false).onText("on").offText("off").build(); + + lamp.use(newCtx(io)); + assertThat(lamp.isOn()).isTrue(); + assertThat(io.outputs()).containsExactly("on"); + + lamp.use(newCtx(io)); + assertThat(lamp.isOn()).isFalse(); + assertThat(io.outputs()).endsWith("off"); + } + + @Test + void switchableItem_initialStateTrue_startsOn() { + SwitchableItem lamp = SwitchableItem.builder() + .id("lamp").name("Lamp").description("d") + .state(true).onText("on").offText("off").build(); + + assertThat(lamp.isOn()).isTrue(); + } + + @Test + void plainItem_use_writesGenericMessage() { + TestIO io = new TestIO(); + PlainItem shovel = PlainItem.builder() + .id("shovel").name("Shovel").description("d").build(); + + shovel.use(newCtx(io)); + + assertThat(io.lastOutput()).contains("Shovel"); + assertThat(io.lastOutput()).contains("can't use"); + } +} diff --git a/Semesterprojekt/src/test/resources/world/game.yaml b/Semesterprojekt/src/test/resources/world/game.yaml new file mode 100644 index 0000000..0be3b8a --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/game.yaml @@ -0,0 +1,6 @@ +title: Test Manor +version: "test" +startRoom: kitchen +startGold: 5 +welcomeMessage: | + Welcome to the test. diff --git a/Semesterprojekt/src/test/resources/world/items.yaml b/Semesterprojekt/src/test/resources/world/items.yaml new file mode 100644 index 0000000..eae04c9 --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/items.yaml @@ -0,0 +1,19 @@ +- type: readable + id: letter + name: Letter + description: A note. + readText: | + hello + +- type: switchable + id: lamp + name: Lamp + description: A lamp. + initialState: false + onText: on + offText: off + +- type: plain + id: key + name: Key + description: A key. diff --git a/Semesterprojekt/src/test/resources/world/npcs.yaml b/Semesterprojekt/src/test/resources/world/npcs.yaml new file mode 100644 index 0000000..c70f171 --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/npcs.yaml @@ -0,0 +1,10 @@ +- id: old_man + name: Old Man + description: stooped + greeting: | + greetings + reactions: + - onReceive: lamp + response: thanks + gives: key + consumes: lamp diff --git a/Semesterprojekt/src/test/resources/world/rooms.yaml b/Semesterprojekt/src/test/resources/world/rooms.yaml new file mode 100644 index 0000000..559da36 --- /dev/null +++ b/Semesterprojekt/src/test/resources/world/rooms.yaml @@ -0,0 +1,15 @@ +- id: kitchen + name: Kitchen + description: kitchen desc + exits: + north: hallway + items: [letter, lamp] + npcs: [old_man] + +- id: hallway + name: Hallway + description: hallway desc + exits: + south: kitchen + items: [] + npcs: []