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
|
||||
out/
|
||||
*.class
|
||||
|
||||
# IDE
|
||||
.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 {
|
||||
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