Compare commits

...

5 Commits

Author SHA1 Message Date
83643a192f semesterprojekt: implement full text adventure (phases 1-7)
Walking skeleton through Swing GUI: YAML-driven world (4 rooms,
4 items, 1 NPC), HashMap command dispatch with parser, three-tier
item hierarchy (readable / switchable / plain), and end-to-end
NPC give/receive flow. 67 tests green.
2026-05-25 21:37:59 +02:00
9b6528d800 uebung_03: implement Task 3 (BST) and Task 4 (IntHashSet)
Task 3 adds IntBinarySearchTree with iterative add/contains and a
test class covering empty trees, duplicates, and degenerate ascending
and descending insertion orders.

Task 4 adds IntHashSet backed by an IntLinkedList bucket array with
a 0.7 load factor, Math.floorMod-based hashing for negative-int
safety, doubling resize that rehashes via a private
addWithoutResize helper, and a test class covering negatives,
Integer.MIN_VALUE, forced collisions on bucket 0, and 100-element
inserts spanning three resizes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 23:19:48 +02:00
964e6a0afb uebung_02: complete IntArrayList with get, size, sort, remove and finish add
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:08:01 +02:00
69b1531a53 chore: ignore compiled .class files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:07:54 +02:00
e5fab1bec5 uebung_03: implement Task 1 (IntLinkedList) and Task 2 (generic LinkedList)
Doubly linked list with head/tail references for int values (Task 1)
and a generic LinkedList<T>/ListNode<T> version (Task 2). Both
implement add, get, remove, getSize with O(n/2) walks via the
closer-end heuristic. Includes IntLinkedListTest covering empty,
single-element, head/tail/middle removal, and out-of-bounds cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:06:24 +02:00
97 changed files with 5205 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Build output # Build output
out/ out/
*.class
# IDE # IDE
.idea/ .idea/

39
Semesterprojekt/.gitignore vendored Normal file
View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

103
Semesterprojekt/pom.xml Normal file
View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>thb.jeanluc</groupId>
<artifactId>Semesterprojekt</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jackson.version>2.18.2</jackson.version>
<lombok.version>1.18.42</lombok.version>
<junit.version>5.11.4</junit.version>
<assertj.version>3.27.0</assertj.version>
<mockito.version>5.14.2</mockito.version>
<logback.version>1.5.15</logback.version>
</properties>
<dependencies>
<!-- YAML / JSON Loading -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Boilerplate Reduction -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<!-- Logging -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>${logback.version}</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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();
}
}

View File

@@ -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();
});
}
}

View File

@@ -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<String> args);
/**
* Short single-line help text shown by the {@code help} command.
*
* @return human-readable usage hint
*/
String help();
}

View File

@@ -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<String> 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<String> args = Arrays.stream(tokens, 1, tokens.length)
.filter(t -> !FILLERS.contains(t))
.toList();
return new ParsedCommand(verb, args);
}
}

View File

@@ -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<String, Command> 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<Command> 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<Command> distinctCommands() {
return new LinkedHashSet<>(commands.values());
}
}

View File

@@ -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<String> args) {
}

View File

@@ -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 <item-id>}.
*/
public class DropCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Drop what?");
return;
}
String itemId = args.getFirst();
Optional<Item> 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 <item> - put an item from your inventory into the room";
}
}

View File

@@ -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 <id>}.
*/
public class ExamineCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Examine what?");
return;
}
String id = args.getFirst();
Optional<Item> 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> 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 <target> - inspect an item or NPC (alias: x)";
}
}

View File

@@ -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 <item> <npc>}.
*/
public class GiveCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.size() < 2) {
ctx.getIo().write("Usage: give <item> <npc>.");
return;
}
String itemId = args.get(0);
String npcId = args.get(1);
Optional<Item> held = ctx.getPlayer().findItem(itemId);
if (held.isEmpty()) {
ctx.getIo().write("You are not carrying '" + itemId + "'.");
return;
}
Optional<Npc> npcOpt = ctx.getPlayer().getCurrentRoom().findNpc(npcId);
if (npcOpt.isEmpty()) {
ctx.getIo().write("There is no '" + npcId + "' here.");
return;
}
Npc npc = npcOpt.get();
Optional<NpcReaction> 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 <item> <npc> - hand an item to an NPC";
}
}

View File

@@ -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 <direction>}.
*/
public class GoCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> 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<Room> 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 <direction> - move to the connected room (north/south/east/west)";
}
}

View File

@@ -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<String> 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: ?)";
}
}

View File

@@ -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<String> 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)";
}
}

View File

@@ -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<String> 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";
}
}

View File

@@ -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<String> args) {
ctx.getIo().write("Goodbye.");
if (game != null) {
game.stop();
}
}
@Override
public String help() {
return "quit - exit the game (alias: exit)";
}
}

View File

@@ -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 <item-id>}.
*/
public class ReadCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Read what?");
return;
}
String itemId = args.getFirst();
Optional<Item> 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 <item> - read the text of a readable item";
}
}

View File

@@ -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 <item-id>}.
*/
public class TakeCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Take what?");
return;
}
String itemId = args.getFirst();
Room room = ctx.getPlayer().getCurrentRoom();
Optional<Item> 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 <item> - pick up an item from the room (alias: pick, get)";
}
}

View File

@@ -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 <npc-id>}.
*/
public class TalkCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Talk to whom?");
return;
}
String npcId = args.getFirst();
Optional<Npc> 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 <npc> - start a conversation with an NPC (alias: speak)";
}
}

View File

@@ -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 <item-id>}.
*/
public class UseCommand implements Command {
@Override
public void execute(GameContext ctx, List<String> args) {
if (args.isEmpty()) {
ctx.getIo().write("Use what?");
return;
}
String itemId = args.getFirst();
Optional<Item> 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 <item> - use an item (read it, switch it on/off, ...)";
}
}

View File

@@ -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<Command> 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;
}
}

View File

@@ -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;
}

View File

@@ -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 &gt; } 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);
}
}

View File

@@ -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);
}

View File

@@ -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<String> 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"));
}
}

View File

@@ -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() + "'");
};
}
}

View File

@@ -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());
}
}

View File

@@ -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<String, Item> items;
/** Global NPC registry, prebuilt by phase 2. */
private final Map<String, Npc> npcs;
/** Global room registry, prebuilt by phase 2. */
private final Map<String, Room> 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<RoomDto> roomDtos) {
for (RoomDto dto : roomDtos) {
Room room = rooms.get(dto.id());
if (dto.exits() != null) {
for (Map.Entry<String, String> 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<NpcDto> 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);
}
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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<ItemDto> itemDtos = readList(basePath + "/items.yaml", ItemDto.class);
List<NpcDto> npcDtos = readList(basePath + "/npcs.yaml", NpcDto.class);
List<RoomDto> 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<String, Item> items = new HashMap<>();
for (ItemDto dto : itemDtos) {
items.put(dto.id(), ItemFactory.fromDto(dto));
}
Map<String, Npc> npcs = new HashMap<>();
for (NpcDto dto : npcDtos) {
npcs.put(dto.id(), NpcFactory.shellFromDto(dto));
}
Map<String, Room> 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 <T> List<T> readList(String resource, Class<T> elementType) {
try (InputStream in = openResource(resource)) {
CollectionType type = yaml.getTypeFactory().constructCollectionType(List.class, elementType);
List<T> result = yaml.readValue(in, type);
return result == null ? List.of() : result;
} catch (IOException e) {
throw new WorldLoadException("Failed to parse " + resource, e);
}
}
private <T> T readSingle(String resource, Class<T> 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<String> 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) + "'");
}
}
}
}
}

View File

@@ -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<String, Item> items;
/** NPC registry to validate. */
private final Map<String, Npc> npcs;
/** Room registry to validate. */
private final Map<String, Room> 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");
}
}
}
}

View File

@@ -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
) {
}

View File

@@ -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
) {
}

View File

@@ -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<ReactionDto> reactions
) {
}

View File

@@ -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
) {
}

View File

@@ -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<String, String> exits,
List<String> items,
List<String> npcs
) {
}

View File

@@ -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();
}
}

View File

@@ -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<String, NpcReaction> 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<NpcReaction> 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);
}
}

View File

@@ -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;
}

View File

@@ -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<String, Item> 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<Item> 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<Item> 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);
}
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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<Direction, Room> exits = new EnumMap<>(Direction.class);
/** Items currently in this room, keyed by item id. Insertion-ordered for stable display. */
private final LinkedHashMap<String, Item> items = new LinkedHashMap<>();
/** NPCs currently in this room, keyed by npc id. Insertion-ordered for stable display. */
private final LinkedHashMap<String, Npc> 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<Room> 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<Item> 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<Item> 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<Npc> findNpc(String npcId) {
return Optional.ofNullable(npcs.get(npcId));
}
}

View File

@@ -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<String, Room> rooms;
/** Global lookup of item prototypes by id. */
private final Map<String, Item> items;
/** Global lookup of NPCs by id. */
private final Map<String, Npc> 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;
}

View File

@@ -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);
}

View File

@@ -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.");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,12 @@
<configuration>
<appender name="STDERR" class="ch.qos.logback.core.ConsoleAppender">
<target>System.err</target>
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level %logger{0} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDERR"/>
</root>
</configuration>

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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: []

View File

@@ -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");
}
}

View File

@@ -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<String> 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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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'");
}
}

View File

@@ -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<String> inputs = new ArrayDeque<>();
private final List<String> outputs = new ArrayList<>();
public TestIO enqueue(String line) {
inputs.add(line);
return this;
}
public List<String> 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);
}
}

View File

@@ -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<String, Item> oneItem(String id) {
Map<String, Item> m = new HashMap<>();
m.put(id, PlainItem.builder().id(id).name(id).description("d").build());
return m;
}
@Test
void resolveRooms_wiresExitsAndItems() {
Map<String, Room> rooms = new HashMap<>();
rooms.put("a", new Room("a", "A", "d"));
rooms.put("b", new Room("b", "B", "d"));
Map<String, Item> 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<String, Room> 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<String, Room> 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<String, Item> items = oneItem("lamp");
items.put("key", PlainItem.builder().id("key").name("Key").description("d").build());
Map<String, Npc> 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<String, Item> items = oneItem("lamp");
Map<String, Npc> 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<String, Item> items = oneItem("lamp");
Map<String, Npc> 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");
}
}

View File

@@ -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"));
}
}

View File

@@ -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<String, Item> items() {
Map<String, Item> m = new HashMap<>();
m.put("lamp", PlainItem.builder().id("lamp").name("Lamp").description("d").build());
return m;
}
private Map<String, Room> rooms() {
Map<String, Room> m = new HashMap<>();
m.put("kitchen", new Room("kitchen", "K", "d"));
return m;
}
private Map<String, Npc> npcs(String greeting) {
Map<String, Npc> 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<String, Room> 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");
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,6 @@
title: Test Manor
version: "test"
startRoom: kitchen
startGold: 5
welcomeMessage: |
Welcome to the test.

View File

@@ -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.

View File

@@ -0,0 +1,10 @@
- id: old_man
name: Old Man
description: stooped
greeting: |
greetings
reactions:
- onReceive: lamp
response: thanks
gives: key
consumes: lamp

View File

@@ -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: []

View File

@@ -14,7 +14,42 @@ public class IntArrayList {
} }
else { else {
int[] arrTemp = new int[lenght*2]; int[] arrTemp = new int[lenght*2];
System.arraycopy(arr, 0, arrTemp, 0, lenght);
arr = arrTemp;
lenght = lenght * 2;
arr[lastUnfilledPos] = a;
lastUnfilledPos ++;
} }
} }
public int get(int pos){
if (pos < 0 || pos >= lastUnfilledPos){
return -1;
}
return arr[pos];
}
public int size(){
return lastUnfilledPos;
}
public void sort(){
int[] view = new int[lastUnfilledPos];
System.arraycopy(arr, 0, view, 0, lastUnfilledPos);
Sorter sorter = new Sorter();
sorter.mergeSort(view);
System.arraycopy(view, 0, arr, 0, lastUnfilledPos);
}
public int remove(int pos){
if (pos < 0 || pos >= lastUnfilledPos){
return -1;
}
int removed = arr[pos];
for (int i = pos; i < lastUnfilledPos -1; i++){
arr[i] = arr[i+1];
}
lastUnfilledPos --;
return removed;
}
} }

View File

@@ -0,0 +1,54 @@
public class IntBinarySearchTree {
IntTreeNode root;
public IntBinarySearchTree(){
}
public void add(int value){
if (root == null){
root = new IntTreeNode(value);
return;
}
IntTreeNode currentRelativRoot = root;
while (true) {
if(value == currentRelativRoot.value){
return;
}
if (value < currentRelativRoot.value) {
if (currentRelativRoot.left == null) {
currentRelativRoot.left = new IntTreeNode(value);
return;
} else currentRelativRoot = currentRelativRoot.left;
} else {
if (currentRelativRoot.right == null) {
currentRelativRoot.right = new IntTreeNode(value);
return;
} else currentRelativRoot = currentRelativRoot.right;
}
}
}
public boolean contains(int value){
if (root == null) return false;
IntTreeNode currentRelativRoot = root;
while (true){
if(value == currentRelativRoot.value){
return true;
}
if (value < currentRelativRoot.value) {
if (currentRelativRoot.left == null) {
return false;
} else currentRelativRoot = currentRelativRoot.left;
} else {
if (currentRelativRoot.right == null) {
return false;
} else currentRelativRoot = currentRelativRoot.right;
}
}
}
}

View File

@@ -0,0 +1,96 @@
public class IntBinarySearchTreeTest {
static int passed = 0;
static int failed = 0;
public static void main(String[] args) {
testEmptyContains();
testSingleAdd();
testStandardTree();
testDuplicate();
testLeftAndRightExtremes();
testStrictlyAscending();
testStrictlyDescending();
System.out.println();
System.out.println("Passed: " + passed + " / " + (passed + failed));
}
static void testEmptyContains() {
IntBinarySearchTree t = new IntBinarySearchTree();
checkBool("empty contains(5)", t.contains(5), false);
}
static void testSingleAdd() {
IntBinarySearchTree t = new IntBinarySearchTree();
t.add(42);
checkBool("single contains(42)", t.contains(42), true);
checkBool("single contains(7)", t.contains(7), false);
}
static void testStandardTree() {
IntBinarySearchTree t = new IntBinarySearchTree();
int[] vals = {5, 3, 8, 1, 4, 7, 9, 5};
for (int v : vals) t.add(v);
checkBool("contains(7)", t.contains(7), true);
checkBool("contains(6)", t.contains(6), false);
checkBool("contains(1)", t.contains(1), true);
checkBool("contains(9)", t.contains(9), true);
checkBool("contains(5)", t.contains(5), true);
checkBool("contains(4)", t.contains(4), true);
checkBool("contains(3)", t.contains(3), true);
checkBool("contains(0)", t.contains(0), false);
checkBool("contains(10)", t.contains(10), false);
}
static void testDuplicate() {
IntBinarySearchTree t = new IntBinarySearchTree();
t.add(10);
t.add(10);
t.add(10);
checkBool("duplicates contains(10)", t.contains(10), true);
// Tree should still work after duplicates
t.add(5);
t.add(15);
checkBool("after dup contains(5)", t.contains(5), true);
checkBool("after dup contains(15)", t.contains(15), true);
}
static void testLeftAndRightExtremes() {
IntBinarySearchTree t = new IntBinarySearchTree();
int[] vals = {50, 25, 75, 10, 35, 60, 90, 5, 15, 30, 40};
for (int v : vals) t.add(v);
for (int v : vals) checkBool("extremes contains(" + v + ")", t.contains(v), true);
checkBool("extremes contains(-1)", t.contains(-1), false);
checkBool("extremes contains(100)", t.contains(100), false);
checkBool("extremes contains(45)", t.contains(45), false);
}
static void testStrictlyAscending() {
// Degenerates into a right-only chain - good edge case
IntBinarySearchTree t = new IntBinarySearchTree();
for (int i = 1; i <= 10; i++) t.add(i);
for (int i = 1; i <= 10; i++) checkBool("asc contains(" + i + ")", t.contains(i), true);
checkBool("asc contains(0)", t.contains(0), false);
checkBool("asc contains(11)", t.contains(11), false);
}
static void testStrictlyDescending() {
// Degenerates into a left-only chain
IntBinarySearchTree t = new IntBinarySearchTree();
for (int i = 10; i >= 1; i--) t.add(i);
for (int i = 1; i <= 10; i++) checkBool("desc contains(" + i + ")", t.contains(i), true);
checkBool("desc contains(0)", t.contains(0), false);
checkBool("desc contains(11)", t.contains(11), false);
}
static void checkBool(String label, boolean actual, boolean expected) {
if (actual == expected) {
System.out.println("PASS " + label + " = " + actual);
passed++;
} else {
System.out.println("FAIL " + label + " expected=" + expected + " actual=" + actual);
failed++;
}
}
}

View File

@@ -0,0 +1,81 @@
public class IntHashSet {
private IntLinkedList[] buckets;
private int size;
private int capacity;
private static final double LOAD_PERCENT = 0.7;
public IntHashSet(){
buckets = new IntLinkedList[16];
capacity = 16;
}
private int hash(int value){
return Math.floorMod(value, capacity);
}
private boolean loadReached(){
return (double) size / capacity >= LOAD_PERCENT;
}
private void resize(){
IntLinkedList[] oldBuckets = buckets;
capacity *=2;
buckets = new IntLinkedList[capacity];
size = 0;
for (IntLinkedList oldBucket : oldBuckets) {
if (oldBucket == null) continue;
for (int j = 0; j < oldBucket.getSize(); j++) {
addWithoutResize(oldBucket.get(j));
}
}
}
public void add(int value){
int index = hash(value);
if (buckets[index] == null){
buckets[index] = new IntLinkedList();
buckets[index].add(value);
size++;
if (loadReached()) resize();
return;
}
for (int i = 0; i < buckets[index].getSize(); i++){
if (buckets[index].get(i) == value){
return;
}
}
buckets[index].add(value);
size++;
if (loadReached()) resize();
}
private void addWithoutResize(int value){
int index = hash(value);
if (buckets[index] == null){
buckets[index] = new IntLinkedList();
buckets[index].add(value);
size++;
return;
}
for (int i = 0; i < buckets[index].getSize(); i++){
if (buckets[index].get(i) == value){
return;
}
}
buckets[index].add(value);
size++;
}
public boolean contains(int value){
int index = hash(value);
if (buckets[index] == null){
return false;
}
for (int i = 0; i < buckets[index].getSize(); i++){
if (buckets[index].get(i) == value){
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,74 @@
public class IntHashSetTest {
static int passed = 0, failed = 0;
public static void main(String[] args) {
testEmpty();
testAddContains();
testDuplicates();
testNegatives();
testManyForcingResize();
testCollisions();
System.out.println();
System.out.println("Passed: " + passed + " / " + (passed + failed));
}
static void testEmpty() {
IntHashSet s = new IntHashSet();
checkBool("empty contains(5)", s.contains(5), false);
checkBool("empty contains(-1)", s.contains(-1), false);
checkBool("empty contains(0)", s.contains(0), false);
}
static void testAddContains() {
IntHashSet s = new IntHashSet();
s.add(1); s.add(2); s.add(3);
checkBool("contains(1)", s.contains(1), true);
checkBool("contains(2)", s.contains(2), true);
checkBool("contains(3)", s.contains(3), true);
checkBool("contains(4)", s.contains(4), false);
}
static void testDuplicates() {
IntHashSet s = new IntHashSet();
s.add(7); s.add(7); s.add(7); s.add(7);
checkBool("dup contains(7)", s.contains(7), true);
// no easy way to inspect size without exposing it; trust it
}
static void testNegatives() {
IntHashSet s = new IntHashSet();
s.add(-1); s.add(-100); s.add(Integer.MIN_VALUE);
checkBool("contains(-1)", s.contains(-1), true);
checkBool("contains(-100)", s.contains(-100), true);
checkBool("contains(MIN_VALUE)", s.contains(Integer.MIN_VALUE), true);
checkBool("contains(1) after negs", s.contains(1), false);
}
static void testManyForcingResize() {
// Insert 100 values - capacity starts at 16, will resize multiple times
IntHashSet s = new IntHashSet();
for (int i = 0; i < 100; i++) s.add(i);
for (int i = 0; i < 100; i++) {
checkBool("post-resize contains(" + i + ")", s.contains(i), true);
}
checkBool("post-resize contains(100)", s.contains(100), false);
checkBool("post-resize contains(-1)", s.contains(-1), false);
}
static void testCollisions() {
// Values that all hash to bucket 0 in capacity 16 (multiples of 16)
IntHashSet s = new IntHashSet();
s.add(0); s.add(16); s.add(32); s.add(48);
checkBool("collision contains(0)", s.contains(0), true);
checkBool("collision contains(16)", s.contains(16), true);
checkBool("collision contains(32)", s.contains(32), true);
checkBool("collision contains(48)", s.contains(48), true);
checkBool("collision contains(8)", s.contains(8), false);
checkBool("collision contains(64)", s.contains(64), false);
}
static void checkBool(String label, boolean actual, boolean expected) {
if (actual == expected) { System.out.println("PASS " + label + " = " + actual); passed++; }
else { System.out.println("FAIL " + label + " expected=" + expected + " actual=" + actual); failed++; }
}
}

View File

@@ -0,0 +1,110 @@
public class IntLinkedList {
private IntListNode headNode;
private IntListNode tailNode;
private int size;
public IntLinkedList() {
headNode = null;
tailNode = null;
size = 0;
}
public void add(int value) {
if (headNode == null) {
headNode = new IntListNode(value, null, null);
tailNode = headNode;
}
else {
IntListNode newNode = new IntListNode(value, null, null);
newNode.prevNode = tailNode;
tailNode.nextNode = newNode;
tailNode = newNode;
}
size++;
}
public int getSize() {
return size;
}
public int get(int index){
if (index < 0 || index > (size - 1)){
throw new IllegalArgumentException("Index Out of Bounds");
}
if (index < size/2){
int pos = 0;
IntListNode curNode = headNode;
while (pos < index){
curNode = curNode.nextNode;
pos++;
}
return curNode.value;
}
else {
int pos = size - 1;
IntListNode curNode = tailNode;
while (pos > index){
curNode = curNode.prevNode;
pos --;
}
return curNode.value;
}
}
public void remove(int index){
if (index < 0 || index > (size - 1)){
throw new IllegalArgumentException("Index Out of Bounds");
}
if(index == 0){
if(size == 1){
headNode = null;
tailNode = null;
}
else {
headNode = headNode.nextNode;
headNode.prevNode = null;
}
size --;
return;
}
if (index == (size - 1)){
tailNode = tailNode.prevNode;
tailNode.nextNode = null;
size --;
return;
}
if (index < size/2){
int pos = 0;
IntListNode curNode = headNode;
while (pos < index){
curNode = curNode.nextNode;
pos++;
}
// Set the prev of the following node so pos +1
curNode.nextNode.prevNode = curNode.prevNode;
// Then set the next of the previous node;
curNode.prevNode.nextNode = curNode.nextNode;
size--;
}
else {
int pos = size - 1;
IntListNode curNode = tailNode;
while (pos > index){
curNode = curNode.prevNode;
pos --;
}
// Set the prev of the following node so pos +1
curNode.nextNode.prevNode = curNode.prevNode;
// Then set the next of the previous node;
curNode.prevNode.nextNode = curNode.nextNode;
size--;
}
}
}

View File

@@ -0,0 +1,166 @@
public class IntLinkedListTest {
static int passed = 0;
static int failed = 0;
public static void main(String[] args) {
testEmpty();
testAddOne();
testAddMany();
testGetAllPositions();
testGetFromBothSides();
testGetOutOfBounds();
testRemoveOnlyElement();
testRemoveHead();
testRemoveTail();
testRemoveMiddleHeadWalk();
testRemoveMiddleTailWalk();
testRemoveOutOfBounds();
testRemoveThenAdd();
testRemoveAllSequentially();
System.out.println();
System.out.println("Passed: " + passed + " / " + (passed + failed));
}
static void testEmpty() {
IntLinkedList l = new IntLinkedList();
check("empty size", l.getSize(), 0);
}
static void testAddOne() {
IntLinkedList l = new IntLinkedList();
l.add(42);
check("size after 1 add", l.getSize(), 1);
check("get(0) after 1 add", l.get(0), 42);
}
static void testAddMany() {
IntLinkedList l = new IntLinkedList();
for (int i = 0; i < 5; i++) l.add(i * 10);
check("size after 5 adds", l.getSize(), 5);
}
static void testGetAllPositions() {
IntLinkedList l = new IntLinkedList();
int[] vals = {10, 20, 30, 40, 50, 60};
for (int v : vals) l.add(v);
for (int i = 0; i < vals.length; i++) {
check("get(" + i + ")", l.get(i), vals[i]);
}
}
static void testGetFromBothSides() {
// size=4 -> indices 0,1 walk from head; 2,3 walk from tail
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2); l.add(3); l.add(4);
check("get(0) head-walk", l.get(0), 1);
check("get(1) head-walk", l.get(1), 2);
check("get(2) tail-walk", l.get(2), 3);
check("get(3) tail-walk", l.get(3), 4);
}
static void testGetOutOfBounds() {
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2);
try { l.get(-1); System.out.println("FAIL get(-1) should throw"); failed++; }
catch (IllegalArgumentException e) { System.out.println("PASS get(-1) throws"); passed++; }
try { l.get(2); System.out.println("FAIL get(size) should throw"); failed++; }
catch (IllegalArgumentException e) { System.out.println("PASS get(size) throws"); passed++; }
}
static void testRemoveOnlyElement() {
IntLinkedList l = new IntLinkedList();
l.add(99);
l.remove(0);
check("remove only: size", l.getSize(), 0);
// Re-add to make sure head/tail were properly reset
l.add(5);
check("remove only then add: size", l.getSize(), 1);
check("remove only then add: get(0)", l.get(0), 5);
}
static void testRemoveHead() {
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2); l.add(3);
l.remove(0);
check("remove head: size", l.getSize(), 2);
check("remove head: get(0)", l.get(0), 2);
check("remove head: get(1)", l.get(1), 3); // uses tail-walk -> tests prev chain
}
static void testRemoveTail() {
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2); l.add(3);
l.remove(2);
check("remove tail: size", l.getSize(), 2);
check("remove tail: get(0)", l.get(0), 1);
check("remove tail: get(1)", l.get(1), 2); // tail-walk -> new tail must be correct
}
static void testRemoveMiddleHeadWalk() {
// size=4, remove index 1 (< size/2 -> head-walk branch)
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2); l.add(3); l.add(4);
l.remove(1);
check("remove mid (head-walk): size", l.getSize(), 3);
check("remove mid (head-walk): get(0)", l.get(0), 1);
check("remove mid (head-walk): get(1)", l.get(1), 3);
check("remove mid (head-walk): get(2)", l.get(2), 4);
}
static void testRemoveMiddleTailWalk() {
// size=6, remove index 4 (>= size/2 -> tail-walk branch)
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2); l.add(3); l.add(4); l.add(5); l.add(6);
l.remove(4);
check("remove mid (tail-walk): size", l.getSize(), 5);
check("remove mid (tail-walk): get(0)", l.get(0), 1);
check("remove mid (tail-walk): get(3)", l.get(3), 4);
check("remove mid (tail-walk): get(4)", l.get(4), 6);
}
static void testRemoveOutOfBounds() {
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2);
try { l.remove(-1); System.out.println("FAIL remove(-1) should throw"); failed++; }
catch (IllegalArgumentException e) { System.out.println("PASS remove(-1) throws"); passed++; }
try { l.remove(2); System.out.println("FAIL remove(size) should throw"); failed++; }
catch (IllegalArgumentException e) { System.out.println("PASS remove(size) throws"); passed++; }
check("oob did not mutate: size", l.getSize(), 2);
}
static void testRemoveThenAdd() {
IntLinkedList l = new IntLinkedList();
l.add(1); l.add(2); l.add(3);
l.remove(2); // remove tail
l.add(99); // new tail
check("remove-then-add: size", l.getSize(), 3);
check("remove-then-add: get(0)", l.get(0), 1);
check("remove-then-add: get(1)", l.get(1), 2);
check("remove-then-add: get(2)", l.get(2), 99);
}
static void testRemoveAllSequentially() {
// Remove from head repeatedly until empty, then re-fill.
IntLinkedList l = new IntLinkedList();
for (int i = 1; i <= 5; i++) l.add(i);
while (l.getSize() > 0) l.remove(0);
check("remove all: size", l.getSize(), 0);
l.add(7); l.add(8);
check("refill after remove-all: size", l.getSize(), 2);
check("refill after remove-all: get(0)", l.get(0), 7);
check("refill after remove-all: get(1)", l.get(1), 8);
}
static void check(String label, int actual, int expected) {
if (actual == expected) {
System.out.println("PASS " + label + " = " + actual);
passed++;
} else {
System.out.println("FAIL " + label + " expected=" + expected + " actual=" + actual);
failed++;
}
}
}

View File

@@ -0,0 +1,11 @@
public class IntListNode {
int value;
IntListNode nextNode;
IntListNode prevNode;
public IntListNode(int value, IntListNode nextNode, IntListNode prevNode) {
this.value = value;
this.nextNode = nextNode;
this.prevNode = prevNode;
}
}

View File

@@ -0,0 +1,12 @@
public class IntTreeNode {
int value;
IntTreeNode left;
IntTreeNode right;
public IntTreeNode(int value){
this.value = value;
}
}

View File

@@ -0,0 +1,100 @@
public class LinkedList<T> {
private ListNode<T> headNode;
private ListNode<T> tailNode;
private int size;
public LinkedList() {
headNode = null;
tailNode = null;
size = 0;
}
public void add(T value) {
if (headNode == null) {
headNode = new ListNode<>(value, null, null);
tailNode = headNode;
}
else {
ListNode<T> newNode = new ListNode<>(value, null, null);
newNode.prevNode = tailNode;
tailNode.nextNode = newNode;
tailNode = newNode;
}
size++;
}
public int getSize() {
return size;
}
public T get(int index) {
if (index < 0 || index > (size - 1)) {
throw new IllegalArgumentException("Index Out of Bounds");
}
if (index < size / 2) {
int pos = 0;
ListNode<T> curNode = headNode;
while (pos < index) {
curNode = curNode.nextNode;
pos++;
}
return curNode.value;
}
else {
int pos = size - 1;
ListNode<T> curNode = tailNode;
while (pos > index) {
curNode = curNode.prevNode;
pos--;
}
return curNode.value;
}
}
public void remove(int index) {
if (index < 0 || index > (size - 1)) {
throw new IllegalArgumentException("Index Out of Bounds");
}
if (index == 0) {
if (size == 1) {
headNode = null;
tailNode = null;
}
else {
headNode = headNode.nextNode;
headNode.prevNode = null;
}
size--;
return;
}
if (index == (size - 1)) {
tailNode = tailNode.prevNode;
tailNode.nextNode = null;
size--;
return;
}
if (index < size / 2) {
int pos = 0;
ListNode<T> curNode = headNode;
while (pos < index) {
curNode = curNode.nextNode;
pos++;
}
curNode.nextNode.prevNode = curNode.prevNode;
curNode.prevNode.nextNode = curNode.nextNode;
size--;
}
else {
int pos = size - 1;
ListNode<T> curNode = tailNode;
while (pos > index) {
curNode = curNode.prevNode;
pos--;
}
curNode.nextNode.prevNode = curNode.prevNode;
curNode.prevNode.nextNode = curNode.nextNode;
size--;
}
}
}

View File

@@ -0,0 +1,11 @@
public class ListNode<T> {
T value;
ListNode<T> nextNode;
ListNode<T> prevNode;
public ListNode(T value, ListNode<T> nextNode, ListNode<T> prevNode) {
this.value = value;
this.nextNode = nextNode;
this.prevNode = prevNode;
}
}