Compare commits
5 Commits
879d6a8721
...
83643a192f
| Author | SHA1 | Date | |
|---|---|---|---|
| 83643a192f | |||
| 9b6528d800 | |||
| 964e6a0afb | |||
| 69b1531a53 | |||
| e5fab1bec5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Build output
|
# Build output
|
||||||
out/
|
out/
|
||||||
|
*.class
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
39
Semesterprojekt/.gitignore
vendored
Normal file
39
Semesterprojekt/.gitignore
vendored
Normal 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
|
||||||
33
Semesterprojekt/docs/README.md
Normal file
33
Semesterprojekt/docs/README.md
Normal 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)
|
||||||
93
Semesterprojekt/docs/architecture.md
Normal file
93
Semesterprojekt/docs/architecture.md
Normal 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.
|
||||||
127
Semesterprojekt/docs/commands.md
Normal file
127
Semesterprojekt/docs/commands.md
Normal 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);
|
||||||
|
}
|
||||||
|
```
|
||||||
88
Semesterprojekt/docs/conventions.md
Normal file
88
Semesterprojekt/docs/conventions.md
Normal 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).
|
||||||
73
Semesterprojekt/docs/data-structures.md
Normal file
73
Semesterprojekt/docs/data-structures.md
Normal 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).
|
||||||
135
Semesterprojekt/docs/implementation-status.md
Normal file
135
Semesterprojekt/docs/implementation-status.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Implementierungsstand & Reihenfolge
|
||||||
|
|
||||||
|
Stand: alle Phasen 1–7 implementiert, 67 Tests grün, End-to-End-Smoke-Test des Walking-Skeletons + YAML-Load erfolgreich.
|
||||||
|
|
||||||
|
## Phasen-Überblick
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
P1["Phase 1<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.
|
||||||
139
Semesterprojekt/docs/item-model.md
Normal file
139
Semesterprojekt/docs/item-model.md
Normal 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.
|
||||||
78
Semesterprojekt/docs/loading-flow.md
Normal file
78
Semesterprojekt/docs/loading-flow.md
Normal 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 |
|
||||||
119
Semesterprojekt/docs/npcs.md
Normal file
119
Semesterprojekt/docs/npcs.md
Normal 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)
|
||||||
143
Semesterprojekt/docs/yaml-schemas.md
Normal file
143
Semesterprojekt/docs/yaml-schemas.md
Normal 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
103
Semesterprojekt/pom.xml
Normal 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>
|
||||||
86
Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java
Normal file
86
Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: ?)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ...)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.PrintStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link GameIO} backed by {@code System.in} and {@code System.out}.
|
||||||
|
* Each {@code write} appends a newline so output blocks are visually
|
||||||
|
* separated. {@code readLine} prints a {@code > } prompt before
|
||||||
|
* blocking.
|
||||||
|
*/
|
||||||
|
public class ConsoleIO implements GameIO {
|
||||||
|
|
||||||
|
/** Input source. */
|
||||||
|
private final BufferedReader in;
|
||||||
|
|
||||||
|
/** Output sink. */
|
||||||
|
private final PrintStream out;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a console IO using standard input and output.
|
||||||
|
*/
|
||||||
|
public ConsoleIO() {
|
||||||
|
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-friendly constructor allowing custom streams.
|
||||||
|
*
|
||||||
|
* @param in reader to consume player input
|
||||||
|
* @param out stream to write player output
|
||||||
|
*/
|
||||||
|
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||||||
|
this.in = in;
|
||||||
|
this.out = out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String readLine() {
|
||||||
|
out.print("> ");
|
||||||
|
out.flush();
|
||||||
|
try {
|
||||||
|
String line = in.readLine();
|
||||||
|
return line == null ? "" : line;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(String s) {
|
||||||
|
out.println(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() + "'");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Semesterprojekt/src/main/resources/logback.xml
Normal file
12
Semesterprojekt/src/main/resources/logback.xml
Normal 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>
|
||||||
7
Semesterprojekt/src/main/resources/world/game.yaml
Normal file
7
Semesterprojekt/src/main/resources/world/game.yaml
Normal 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.
|
||||||
24
Semesterprojekt/src/main/resources/world/items.yaml
Normal file
24
Semesterprojekt/src/main/resources/world/items.yaml
Normal 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.
|
||||||
12
Semesterprojekt/src/main/resources/world/npcs.yaml
Normal file
12
Semesterprojekt/src/main/resources/world/npcs.yaml
Normal 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
|
||||||
41
Semesterprojekt/src/main/resources/world/rooms.yaml
Normal file
41
Semesterprojekt/src/main/resources/world/rooms.yaml
Normal 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: []
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Semesterprojekt/src/test/resources/world/game.yaml
Normal file
6
Semesterprojekt/src/test/resources/world/game.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
title: Test Manor
|
||||||
|
version: "test"
|
||||||
|
startRoom: kitchen
|
||||||
|
startGold: 5
|
||||||
|
welcomeMessage: |
|
||||||
|
Welcome to the test.
|
||||||
19
Semesterprojekt/src/test/resources/world/items.yaml
Normal file
19
Semesterprojekt/src/test/resources/world/items.yaml
Normal 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.
|
||||||
10
Semesterprojekt/src/test/resources/world/npcs.yaml
Normal file
10
Semesterprojekt/src/test/resources/world/npcs.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
- id: old_man
|
||||||
|
name: Old Man
|
||||||
|
description: stooped
|
||||||
|
greeting: |
|
||||||
|
greetings
|
||||||
|
reactions:
|
||||||
|
- onReceive: lamp
|
||||||
|
response: thanks
|
||||||
|
gives: key
|
||||||
|
consumes: lamp
|
||||||
15
Semesterprojekt/src/test/resources/world/rooms.yaml
Normal file
15
Semesterprojekt/src/test/resources/world/rooms.yaml
Normal 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: []
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
uebung_03/src/IntBinarySearchTree.java
Normal file
54
uebung_03/src/IntBinarySearchTree.java
Normal 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;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
96
uebung_03/src/IntBinarySearchTreeTest.java
Normal file
96
uebung_03/src/IntBinarySearchTreeTest.java
Normal 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
uebung_03/src/IntHashSet.java
Normal file
81
uebung_03/src/IntHashSet.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
uebung_03/src/IntHashSetTest.java
Normal file
74
uebung_03/src/IntHashSetTest.java
Normal 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++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
110
uebung_03/src/IntLinkedList.java
Normal file
110
uebung_03/src/IntLinkedList.java
Normal 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--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
166
uebung_03/src/IntLinkedListTest.java
Normal file
166
uebung_03/src/IntLinkedListTest.java
Normal 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
uebung_03/src/IntListNode.java
Normal file
11
uebung_03/src/IntListNode.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
uebung_03/src/IntTreeNode.java
Normal file
12
uebung_03/src/IntTreeNode.java
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
public class IntTreeNode {
|
||||||
|
int value;
|
||||||
|
IntTreeNode left;
|
||||||
|
|
||||||
|
IntTreeNode right;
|
||||||
|
|
||||||
|
public IntTreeNode(int value){
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
100
uebung_03/src/LinkedList.java
Normal file
100
uebung_03/src/LinkedList.java
Normal 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--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
uebung_03/src/ListNode.java
Normal file
11
uebung_03/src/ListNode.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user