Compare commits
51 Commits
3ae2dc2175
...
dacac81382
| Author | SHA1 | Date | |
|---|---|---|---|
| dacac81382 | |||
| b5d603a7b1 | |||
| 08c92a57de | |||
| 2891553629 | |||
| bcd2ae9ddd | |||
| 2bad875df1 | |||
| d0d8648bd0 | |||
| 35ad4e6f1a | |||
| a38bb6e4f3 | |||
| 7f64c9f9e3 | |||
| a1fd56b389 | |||
| d0e290c723 | |||
| 36f60863ad | |||
| a0e21a9baa | |||
| ffc4d7acab | |||
| 20d076832d | |||
| c5993d1236 | |||
| 1e980a80e2 | |||
| 19ea7667b0 | |||
| 2ed6a71cea | |||
| 28871b4ddc | |||
| 546a7672bf | |||
| f47a3e7ca5 | |||
| 95098fbb09 | |||
| 5c9a8cb568 | |||
| 055a256b03 | |||
| ba63ce7b47 | |||
| 930915da59 | |||
| 6d1b36f853 | |||
| c01c47a585 | |||
| a30c153de1 | |||
| 663a78ed9e | |||
| 7c1cdc545e | |||
| 7b60230f1e | |||
| d10632b800 | |||
| c10590028e | |||
| 966762fff9 | |||
| 1bdae4f295 | |||
| 8d88157573 | |||
| 63af4c7641 | |||
| a89684807b | |||
| e7b9891771 | |||
| 89962b4b60 | |||
| 667991df26 | |||
| 3d183ad162 | |||
| 9f9b0c0808 | |||
| bbc4fc37f8 | |||
| b7094bf152 | |||
| 2e48ec7221 | |||
| 34cbb7f158 | |||
| 6887102d19 |
3
Semesterprojekt/.gitignore
vendored
3
Semesterprojekt/.gitignore
vendored
@@ -43,3 +43,6 @@ build/
|
|||||||
|
|
||||||
# Local save games and settings
|
# Local save games and settings
|
||||||
saves/
|
saves/
|
||||||
|
|
||||||
|
# External background-music files (not bundled)
|
||||||
|
music/
|
||||||
|
|||||||
@@ -129,6 +129,12 @@ des Bogens; einzelne Teilziele schalten je den nächsten Bereich frei.
|
|||||||
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
4. **Item-Kombination** – z.B. `match + candle → lit candle`. Achtung: v1.0 hat
|
||||||
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
|
||||||
werden (jetzt erlaubt, da über MVP hinaus).
|
werden (jetzt erlaubt, da über MVP hinaus).
|
||||||
|
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
|
||||||
|
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
|
||||||
|
> Operanden aus Inventar oder Raum, reihenfolge-unabhängiger Paar-Key. Kein
|
||||||
|
> Parser-Eingriff (`on`/`with`/`to` sind bereits Filler). Selbst-Kombination
|
||||||
|
> (a==b) wird beim Laden abgelehnt. Demo: `matches` + `torch` → `lit_torch`
|
||||||
|
> (dauerhafte Lichtquelle).
|
||||||
5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette
|
5. **Quest-Ketten + mehrere Enden** – Teilziele referenzieren Flags; Kette
|
||||||
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
|
||||||
|
|
||||||
@@ -174,6 +180,14 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
|
|
||||||
### 8. Musik (GUI)
|
### 8. Musik (GUI)
|
||||||
|
|
||||||
|
> ✅ umgesetzt (Branch `feature/music`): OGG Vorbis (vorbisspi/jorbis), pro Raum
|
||||||
|
> via `music:`-Feld in `rooms.yaml`, externer gitignored `music/`-Ordner (nicht im
|
||||||
|
> JAR). Idempotenter Track-Wechsel mit ~400 ms Fades; Räume ohne `music` blenden zu
|
||||||
|
> Stille; gleicher Track läuft nahtlos weiter. Music-Stufe Off/Low/Med/High in den
|
||||||
|
> Settings (Default Medium), persistiert. Konsole stumm. Testbarer `MusicController`
|
||||||
|
> über isoliertes `OggMusicBackend`; fehlende Datei → still, kein Absturz. Siehe
|
||||||
|
> `docs/music.md`.
|
||||||
|
|
||||||
- Hintergrundmusik **nur in der GUI**.
|
- Hintergrundmusik **nur in der GUI**.
|
||||||
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
|
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
|
||||||
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
|
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
|
||||||
@@ -189,8 +203,18 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
|
|||||||
|
|
||||||
## Festgelegte Erweiterungs-Entscheidungen
|
## Festgelegte Erweiterungs-Entscheidungen
|
||||||
|
|
||||||
> **Pathfinding** (BFS `go to <raum>`, Trie-Autocomplete) bleibt für eine
|
> ✅ **Pathfinding** umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>`
|
||||||
> spätere Runde im Scope.
|
> per BFS über besuchte Räume (Locks + Licht beachtet), via `GoCommand`
|
||||||
|
> (Richtung vs. Raumziel). Konsole still + Zusammenfassung (Start/Mitte/Ziel),
|
||||||
|
> GUI-Minikarte animiert pro Schritt (`GameIO.travelStep`, ~300 ms).
|
||||||
|
> **Trie-Autocomplete** weiterhin für eine spätere Runde zurückgestellt.
|
||||||
|
|
||||||
|
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
|
||||||
|
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
|
||||||
|
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel),
|
||||||
|
> Abschluss-Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
|
||||||
|
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
|
||||||
|
> Spiel, nicht beim Laden.
|
||||||
|
|
||||||
| Entscheidung | Wert |
|
| Entscheidung | Wert |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
17
Semesterprojekt/docs/music.md
Normal file
17
Semesterprojekt/docs/music.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Background music (GUI)
|
||||||
|
|
||||||
|
Background music plays only in the Swing GUI. It is **data-driven and external**:
|
||||||
|
|
||||||
|
- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game.
|
||||||
|
This folder is git-ignored and never bundled in the JAR.
|
||||||
|
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
|
||||||
|
`music: manor-theme.ogg`. Entering a room with a different track fades the old one
|
||||||
|
out and the new one in (sequential, not an overlapping cross-fade);
|
||||||
|
a room with no `music:` field fades to silence; the same track keeps playing
|
||||||
|
seamlessly across rooms.
|
||||||
|
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
|
||||||
|
persisted to `saves/settings.json`. Off disables playback.
|
||||||
|
- Missing files are ignored (silent) — the game never crashes over absent audio.
|
||||||
|
- The console version is always silent.
|
||||||
|
|
||||||
|
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.
|
||||||
860
Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md
Normal file
860
Semesterprojekt/docs/superpowers/plans/2026-06-01-music.md
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
# GUI Background Music Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add per-room background music in the GUI — OGG tracks streamed from an external `music/` folder, fading on room change, with an Off/Low/Med/High Settings control. Console stays silent.
|
||||||
|
|
||||||
|
**Architecture:** A testable `MusicController` holds the track/level decision logic (idempotent same-track, null→silence, OFF disables) and delegates raw operations to a `MusicBackend`. `OggMusicBackend` (the only audio/thread code, manually verified) decodes OGG via vorbisspi/jorbis and streams PCM through a `SourceDataLine` with gain fades. `GameIO.setMusic`/`setMusicLevel` are console no-ops; `SwingIO` owns the controller. `Game.publishHud` pushes the current room's track each turn; Settings carries a persisted `MusicLevel`.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, vorbisspi+jorbis (OGG), Jackson (YAML), JUnit 5 + AssertJ, Lombok.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:** `io/MusicLevel.java`, `io/MusicBackend.java`, `io/MusicController.java`, `io/OggMusicBackend.java`; tests `MusicLevelTest`, `MusicControllerTest`; `docs/music.md`.
|
||||||
|
**Modify:** `io/GameIO.java` (seams), `io/SwingIO.java` (wire controller), `io/ConsoleIO.java` (none — inherits no-op), `game/Game.java` (publishHud), `App.java` (setMusic(null) + settings apply), `model/Room.java` + `loader/dto/RoomDto.java` + `loader/RoomFactory.java` (music field), `save/Settings.java` + `save/SettingsStore`-tests + `menu/SettingsMenu.java` + `test/.../menu/MenuTest.java`, `src/main/resources/world/rooms.yaml`, `pom.xml`, `.gitignore`, `docs/enhancement-ideas.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `MusicLevel` enum
|
||||||
|
|
||||||
|
**Files:** Create `src/main/java/thb/jeanluc/adventure/io/MusicLevel.java`; Test `src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/io/MusicLevelTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MusicLevelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gainIncreasesWithLevel() {
|
||||||
|
assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb());
|
||||||
|
assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isOnOnlyWhenNotOff() {
|
||||||
|
assertThat(MusicLevel.OFF.isOn()).isFalse();
|
||||||
|
assertThat(MusicLevel.LOW.isOn()).isTrue();
|
||||||
|
assertThat(MusicLevel.HIGH.isOn()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nextCyclesThroughAllAndWraps() {
|
||||||
|
assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW);
|
||||||
|
assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM);
|
||||||
|
assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH);
|
||||||
|
assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (`mvn -q test -Dtest=MusicLevelTest` → MusicLevel missing).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `MusicLevel`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/io/MusicLevel.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Background-music volume level, cycled in the Settings menu. */
|
||||||
|
public enum MusicLevel {
|
||||||
|
OFF, LOW, MEDIUM, HIGH;
|
||||||
|
|
||||||
|
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
|
||||||
|
public float gainDb() {
|
||||||
|
return switch (this) {
|
||||||
|
case OFF -> Float.NEGATIVE_INFINITY;
|
||||||
|
case LOW -> -20f;
|
||||||
|
case MEDIUM -> -10f;
|
||||||
|
case HIGH -> 0f;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true when music should play. */
|
||||||
|
public boolean isOn() {
|
||||||
|
return this != OFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next level in the cycle (wraps HIGH → OFF). */
|
||||||
|
public MusicLevel next() {
|
||||||
|
return values()[(ordinal() + 1) % values().length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run — PASS** (`mvn -q test -Dtest=MusicLevelTest`).
|
||||||
|
- [ ] **Step 5: Commit** — `git add` the two files; `git commit -m "feat(io): MusicLevel enum (Off/Low/Med/High)"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `MusicBackend` + `MusicController`
|
||||||
|
|
||||||
|
**Files:** Create `io/MusicBackend.java`, `io/MusicController.java`; Test `io/MusicControllerTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/io/MusicControllerTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MusicControllerTest {
|
||||||
|
|
||||||
|
/** Records backend calls for assertions. */
|
||||||
|
private static final class FakeBackend implements MusicBackend {
|
||||||
|
final List<String> calls = new ArrayList<>();
|
||||||
|
public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); }
|
||||||
|
public void fadeOut() { calls.add("fadeOut"); }
|
||||||
|
public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); }
|
||||||
|
public void shutdown() { calls.add("shutdown"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstRoomWithTrackPlaysAtDefaultLevel() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
new MusicController(b).room("a");
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameTrackDoesNotRestart() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
b.calls.clear();
|
||||||
|
c.room("a");
|
||||||
|
assertThat(b.calls).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentTrackSwitches() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.room("b");
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullOrBlankRoomFadesToSilence() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.room(null);
|
||||||
|
c.room(" ");
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void offStopsAndIgnoresRooms() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.level(MusicLevel.OFF);
|
||||||
|
b.calls.clear();
|
||||||
|
c.room("b");
|
||||||
|
assertThat(b.calls).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void levelChangeSetsGain() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b); // default MEDIUM
|
||||||
|
c.level(MusicLevel.HIGH);
|
||||||
|
assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turningOffThenOnReplaysOnNextRoom() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.level(MusicLevel.OFF); // fadeOut
|
||||||
|
c.level(MusicLevel.MEDIUM); // setGain
|
||||||
|
b.calls.clear();
|
||||||
|
c.room("a"); // current was reset → plays again
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL.**
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `MusicBackend`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/io/MusicBackend.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Raw audio operations — the only part that touches sound hardware. */
|
||||||
|
public interface MusicBackend {
|
||||||
|
|
||||||
|
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
|
||||||
|
void fadeTo(String track, float gainDb);
|
||||||
|
|
||||||
|
/** Fade the current track out and stop. */
|
||||||
|
void fadeOut();
|
||||||
|
|
||||||
|
/** Set the playback volume immediately. */
|
||||||
|
void setGainDb(float gainDb);
|
||||||
|
|
||||||
|
/** Stop playback and release resources. */
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `MusicController`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/io/MusicController.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
|
||||||
|
* the same track), fades to silence on a null/blank track, and honours the
|
||||||
|
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
|
||||||
|
* {@link MusicBackend}.
|
||||||
|
*/
|
||||||
|
public final class MusicController {
|
||||||
|
|
||||||
|
private final MusicBackend backend;
|
||||||
|
private MusicLevel level = MusicLevel.MEDIUM;
|
||||||
|
private String current; // currently selected track, or null = silence
|
||||||
|
|
||||||
|
public MusicController(MusicBackend backend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called per turn with the current room's music field (may be null/blank). */
|
||||||
|
public void room(String track) {
|
||||||
|
if (!level.isOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String next = (track == null || track.isBlank()) ? null : track;
|
||||||
|
if (Objects.equals(next, current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
if (next == null) {
|
||||||
|
backend.fadeOut();
|
||||||
|
} else {
|
||||||
|
backend.fadeTo(next, level.gainDb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a new music level (volume / off). */
|
||||||
|
public void level(MusicLevel newLevel) {
|
||||||
|
this.level = newLevel;
|
||||||
|
if (!newLevel.isOn()) {
|
||||||
|
backend.fadeOut();
|
||||||
|
current = null; // turning back on replays on the next room() call
|
||||||
|
} else {
|
||||||
|
backend.setGainDb(newLevel.gainDb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
backend.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run — PASS** (`mvn -q test -Dtest=MusicControllerTest`, 7 tests).
|
||||||
|
- [ ] **Step 6: Commit** — `git commit -m "feat(io): MusicController + MusicBackend (testable music logic)"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `Room.music` field + loader
|
||||||
|
|
||||||
|
**Files:** Modify `model/Room.java`, `loader/dto/RoomDto.java`, `loader/RoomFactory.java`, `src/main/resources/world/rooms.yaml`; Test `loader/RoomMusicTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/RoomMusicTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class RoomMusicTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void musicFieldIsCarriedOntoRoom() {
|
||||||
|
RoomDto dto = new RoomDto("r", "Room", "d",
|
||||||
|
Map.of(), List.of(), List.of(), null, null, null, "theme.ogg");
|
||||||
|
Room room = RoomFactory.shellFromDto(dto);
|
||||||
|
assertThat(room.getMusic()).isEqualTo("theme.ogg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void absentMusicIsNull() {
|
||||||
|
RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of());
|
||||||
|
Room room = RoomFactory.shellFromDto(dto);
|
||||||
|
assertThat(room.getMusic()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (no `getMusic`, RoomDto has no music arg).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `music` to `Room`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/model/Room.java`, after the `dark` field (which uses `@Setter`), add:
|
||||||
|
```java
|
||||||
|
/** Optional GUI background-music track filename for this room; null = none. */
|
||||||
|
@Setter
|
||||||
|
private String music;
|
||||||
|
```
|
||||||
|
(`@Getter` on the class provides `getMusic()`; `@Setter` provides `setMusic(String)`, mirroring `dark`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `music` to `RoomDto`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/loader/dto/RoomDto.java`, add `String music` as the final record component and update the backward-compatible constructor:
|
||||||
|
```java
|
||||||
|
public record RoomDto(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
Map<String, String> exits,
|
||||||
|
List<String> items,
|
||||||
|
List<String> npcs,
|
||||||
|
List<ExitLockDto> exitLocks,
|
||||||
|
List<DescriptionStateDto> descriptionStates,
|
||||||
|
Boolean dark,
|
||||||
|
String music
|
||||||
|
) {
|
||||||
|
/** Backward-compatible constructor without the optional state fields. */
|
||||||
|
public RoomDto(String id, String name, String description,
|
||||||
|
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||||
|
this(id, name, description, exits, items, npcs, null, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Carry it in `RoomFactory`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/loader/RoomFactory.java`, in `shellFromDto`, after `room.setDark(...)`:
|
||||||
|
```java
|
||||||
|
room.setMusic(dto.music());
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add demo `music:` fields**
|
||||||
|
|
||||||
|
In `src/main/resources/world/rooms.yaml`, add a `music:` line to two rooms (files supplied externally; absent → silent). For the `kitchen` room add ` music: manor-theme.ogg` and for the `dungeon` room add ` music: dungeon-drone.ogg` (place the line among the room's other top-level keys, e.g. after its `description`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run — PASS** (`mvn -q test -Dtest=RoomMusicTest`), then `mvn -q clean test` (full suite green; WorldLoaderTest uses its own fixture base, unaffected by the production rooms.yaml `music:` additions).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** — `git commit -m "feat(loader): per-room music field + demo rooms.yaml entries"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `GameIO` music seams + game-loop / app wiring
|
||||||
|
|
||||||
|
**Files:** Modify `io/GameIO.java`, `game/Game.java`, `App.java`; Test `io/MusicSeamDefaultTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/io/MusicSeamDefaultTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MusicSeamDefaultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultMusicSeamsAreSilentNoOps() {
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
io.setMusic("anything.ogg");
|
||||||
|
io.setMusic(null);
|
||||||
|
io.setMusicLevel(MusicLevel.HIGH);
|
||||||
|
assertThat(io.outputs()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (no `setMusic`/`setMusicLevel`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the default seams to `GameIO`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/io/GameIO.java`, add (MusicLevel is in the same package — no import needed):
|
||||||
|
```java
|
||||||
|
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
|
||||||
|
default void setMusic(String track) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the music volume/level. Console no-op; GUI sets it. */
|
||||||
|
default void setMusicLevel(MusicLevel level) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push the current track each turn in `Game`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/game/Game.java`, at the END of `publishHud()` (after `setQuests(...)`):
|
||||||
|
```java
|
||||||
|
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Silence music on return to menu in `App`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/App.java`, in `play(...)`, immediately after `game.run();`:
|
||||||
|
```java
|
||||||
|
io.setMusic(null); // fade music out when returning to the menu
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run — PASS** (`mvn -q test -Dtest=MusicSeamDefaultTest`), then `mvn -q clean test` (full suite green; console/loop tests unaffected — seams are no-ops).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit** — `git commit -m "feat(io): GameIO music seams; push room track per turn, silence at menu"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Settings `MusicLevel` + SettingsMenu refactor to `GameIO`
|
||||||
|
|
||||||
|
**Files:** Modify `save/Settings.java`, `menu/SettingsMenu.java`, `App.java`, `test/.../menu/MenuTest.java`, `test/.../save/SettingsStoreTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Add to `src/test/java/thb/jeanluc/adventure/save/SettingsStoreTest.java` (keep existing tests; add imports `thb.jeanluc.adventure.io.MusicLevel`):
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void musicLevelRoundTrips(@TempDir Path dir) {
|
||||||
|
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||||
|
store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH));
|
||||||
|
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("settings.json");
|
||||||
|
Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}");
|
||||||
|
assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Add `import java.nio.file.Files;` / `Path` / `@TempDir` if not already present — the existing tests already use `@TempDir`.)
|
||||||
|
|
||||||
|
Update the music-toggle expectation in `src/test/java/thb/jeanluc/adventure/menu/MenuTest.java` — the existing `settingsMenuTogglesGlyphAndPersists` test calls `SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO())`. Change it to the new signature and account for the new "Music" row (options become Colour=1, Glyphs=2, Music=3, Back=4):
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
|
||||||
|
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||||
|
TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back
|
||||||
|
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
|
||||||
|
assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||||
|
assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add a music-cycle test:
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) {
|
||||||
|
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||||
|
TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back
|
||||||
|
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
|
||||||
|
assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH
|
||||||
|
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Add `import thb.jeanluc.adventure.io.MusicLevel;` to MenuTest.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run — expect FAIL** (Settings has no musicLevel; show signature differs).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend `Settings`**
|
||||||
|
|
||||||
|
Replace `src/main/java/thb/jeanluc/adventure/save/Settings.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.save;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
|
import thb.jeanluc.adventure.io.MusicLevel;
|
||||||
|
|
||||||
|
/** Persisted user preferences (colour + glyph fidelity + music level). */
|
||||||
|
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) {
|
||||||
|
|
||||||
|
public Settings {
|
||||||
|
if (musicLevel == null) {
|
||||||
|
musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for callers that don't set music. */
|
||||||
|
public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
|
||||||
|
this(colorMode, glyphMode, MusicLevel.MEDIUM);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Settings defaults() {
|
||||||
|
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Refactor `SettingsMenu` to `GameIO` + add the Music row**
|
||||||
|
|
||||||
|
Replace `src/main/java/thb/jeanluc/adventure/menu/SettingsMenu.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.menu;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
|
import thb.jeanluc.adventure.io.GameIO;
|
||||||
|
import thb.jeanluc.adventure.io.MusicLevel;
|
||||||
|
import thb.jeanluc.adventure.save.Settings;
|
||||||
|
import thb.jeanluc.adventure.save.SettingsStore;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
|
||||||
|
public final class SettingsMenu {
|
||||||
|
|
||||||
|
private SettingsMenu() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loops the settings screen until "Back", persisting + applying each change. */
|
||||||
|
public static Settings show(GameIO io, Settings current, SettingsStore store) {
|
||||||
|
Settings s = current;
|
||||||
|
while (true) {
|
||||||
|
int i = io.choose("SETTINGS", List.of(
|
||||||
|
"Colour: " + s.colorMode(),
|
||||||
|
"Glyphs: " + s.glyphMode(),
|
||||||
|
"Music: " + s.musicLevel(),
|
||||||
|
"Back"));
|
||||||
|
if (i == 0) {
|
||||||
|
s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
|
||||||
|
} else if (i == 1) {
|
||||||
|
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
|
||||||
|
} else if (i == 2) {
|
||||||
|
s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
|
||||||
|
} else {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
store.save(s);
|
||||||
|
apply(s, io);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies settings to the IO: music level always; colour/glyph only on the console. */
|
||||||
|
public static void apply(Settings s, GameIO io) {
|
||||||
|
io.setMusicLevel(s.musicLevel());
|
||||||
|
if (io instanceof ConsoleIO c) {
|
||||||
|
c.setColorMode(s.colorMode());
|
||||||
|
c.setGlyphMode(s.glyphMode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConsoleIO.ColorMode nextColor(ConsoleIO.ColorMode m) {
|
||||||
|
return switch (m) {
|
||||||
|
case AUTO -> ConsoleIO.ColorMode.ON;
|
||||||
|
case ON -> ConsoleIO.ColorMode.OFF;
|
||||||
|
case OFF -> ConsoleIO.ColorMode.AUTO;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConsoleIO.GlyphMode nextGlyph(ConsoleIO.GlyphMode m) {
|
||||||
|
return switch (m) {
|
||||||
|
case UNICODE -> ConsoleIO.GlyphMode.ASCII;
|
||||||
|
case ASCII -> ConsoleIO.GlyphMode.GLYPH;
|
||||||
|
case GLYPH -> ConsoleIO.GlyphMode.UNICODE;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `App` call sites**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/App.java`, in `run()`:
|
||||||
|
- Remove the now-unused `ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;` line (if it becomes unused after the changes below).
|
||||||
|
- Change the startup apply to `SettingsMenu.apply(settings, io);`.
|
||||||
|
- Change the SETTINGS case to `case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);`.
|
||||||
|
|
||||||
|
(If `ConsoleIO` import becomes unused in `App.java`, remove it. `io` is the `GameIO`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run — PASS** (`mvn -q test -Dtest=SettingsStoreTest,MenuTest,ConsoleIORenderTest`), then `mvn -q clean test` (full suite green).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit** — `git commit -m "feat(settings): persisted Music level; SettingsMenu applies via GameIO"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: OGG dependency + `OggMusicBackend` + SwingIO wiring
|
||||||
|
|
||||||
|
**Files:** Modify `pom.xml`, `io/SwingIO.java`, `.gitignore`; Create `io/OggMusicBackend.java`, `docs/music.md`. (No unit test — audio is manually verified per the no-GUI-test convention.)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the OGG dependencies to `pom.xml`**
|
||||||
|
|
||||||
|
In the `<dependencies>` block:
|
||||||
|
```xml
|
||||||
|
<!-- OGG Vorbis decoding (GUI background music) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>vorbisspi</artifactId>
|
||||||
|
<version>1.0.3.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>jorbis</artifactId>
|
||||||
|
<version>0.0.17.4</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
Run `mvn -q -DskipTests compile` to confirm the dependencies resolve.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `OggMusicBackend`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.sound.sampled.AudioFormat;
|
||||||
|
import javax.sound.sampled.AudioInputStream;
|
||||||
|
import javax.sound.sampled.AudioSystem;
|
||||||
|
import javax.sound.sampled.FloatControl;
|
||||||
|
import javax.sound.sampled.SourceDataLine;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams OGG Vorbis tracks from the external {@code music/} folder through a
|
||||||
|
* {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
|
||||||
|
* vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
|
||||||
|
* thread; missing/undecodable files are logged and stay silent. GUI-only —
|
||||||
|
* verified manually.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class OggMusicBackend implements MusicBackend {
|
||||||
|
|
||||||
|
private static final String MUSIC_DIR = "music";
|
||||||
|
private static final int FADE_MS = 400;
|
||||||
|
private static final int FADE_STEPS = 20;
|
||||||
|
private static final int BUFFER = 4096;
|
||||||
|
private static final float FLOOR_DB = -40f; // near-silence for fades
|
||||||
|
|
||||||
|
private volatile Thread playThread;
|
||||||
|
private volatile boolean stopping;
|
||||||
|
private volatile SourceDataLine line;
|
||||||
|
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeTo(String track, float gainDb) {
|
||||||
|
stopCurrent();
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
Path file = Path.of(MUSIC_DIR, track);
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
log.warn("Music file not found, staying silent: {}", file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopping = false;
|
||||||
|
playThread = new Thread(() -> playLoop(file, gainDb), "music");
|
||||||
|
playThread.setDaemon(true);
|
||||||
|
playThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeOut() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void setGainDb(float gainDb) {
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
SourceDataLine l = line;
|
||||||
|
if (l != null) {
|
||||||
|
applyGain(l, gainDb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void shutdown() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
|
||||||
|
private void stopCurrent() {
|
||||||
|
Thread t = playThread;
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopping = true;
|
||||||
|
try {
|
||||||
|
t.join(FADE_MS + 600L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
playThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void playLoop(Path file, float gainDb) {
|
||||||
|
SourceDataLine l = null;
|
||||||
|
try {
|
||||||
|
while (!stopping) {
|
||||||
|
try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
|
||||||
|
AudioFormat base = in.getFormat();
|
||||||
|
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
||||||
|
base.getSampleRate(), 16, base.getChannels(),
|
||||||
|
base.getChannels() * 2, base.getSampleRate(), false);
|
||||||
|
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
|
||||||
|
if (l == null) {
|
||||||
|
l = AudioSystem.getSourceDataLine(pcm);
|
||||||
|
l.open(pcm);
|
||||||
|
l.start();
|
||||||
|
line = l;
|
||||||
|
ramp(l, FLOOR_DB, gainDb); // fade in once
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[BUFFER];
|
||||||
|
int n;
|
||||||
|
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
|
||||||
|
l.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Music playback failed for {}: {}", file, e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (l != null) {
|
||||||
|
try {
|
||||||
|
ramp(l, currentGain(l), FLOOR_DB); // fade out
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// best-effort fade
|
||||||
|
}
|
||||||
|
l.stop();
|
||||||
|
l.close();
|
||||||
|
}
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ramp(SourceDataLine l, float fromDb, float toDb) {
|
||||||
|
for (int i = 0; i <= FADE_STEPS; i++) {
|
||||||
|
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
||||||
|
sleep(FADE_MS / FADE_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float currentGain(SourceDataLine l) {
|
||||||
|
try {
|
||||||
|
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return targetGainDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyGain(SourceDataLine l, float db) {
|
||||||
|
try {
|
||||||
|
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
|
||||||
|
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// line has no master-gain control on this platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If any call doesn't compile against the installed vorbisspi/`javax.sound` API, adjust minimally to compile while preserving the behaviour (decode OGG→PCM, stream with looping, MASTER_GAIN fades, graceful on missing file). This class is manually verified.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the controller into `SwingIO`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`:
|
||||||
|
- Add a field (with the other final fields): `private final MusicController music = new MusicController(new OggMusicBackend());`
|
||||||
|
- Add the overrides (near `setMap`):
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public void setMusic(String track) {
|
||||||
|
music.room(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMusicLevel(MusicLevel level) {
|
||||||
|
music.level(level);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`MusicController`/`MusicLevel`/`OggMusicBackend` are in the same `io` package — no imports needed.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Ignore the music folder**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
```
|
||||||
|
# External background-music files (not bundled)
|
||||||
|
music/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Document the music folder**
|
||||||
|
|
||||||
|
Create `docs/music.md`:
|
||||||
|
```markdown
|
||||||
|
# Background music (GUI)
|
||||||
|
|
||||||
|
Background music plays only in the Swing GUI. It is **data-driven and external**:
|
||||||
|
|
||||||
|
- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game.
|
||||||
|
This folder is git-ignored and never bundled in the JAR.
|
||||||
|
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
|
||||||
|
`music: manor-theme.ogg`. Entering a room with a different track cross-fades to it;
|
||||||
|
a room with no `music:` field fades to silence; the same track keeps playing
|
||||||
|
seamlessly across rooms.
|
||||||
|
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
|
||||||
|
persisted to `saves/settings.json`. Off disables playback.
|
||||||
|
- Missing files are ignored (silent) — the game never crashes over absent audio.
|
||||||
|
- The console version is always silent.
|
||||||
|
|
||||||
|
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build + full suite + manual check**
|
||||||
|
|
||||||
|
Run: `mvn -q clean test` → BUILD SUCCESS (all prior + new tests; the audio backend has no unit test).
|
||||||
|
Manual (optional, needs audio): drop an `.ogg` as `music/manor-theme.ogg`, launch the GUI, New Game → the kitchen should play it; move to the dungeon → it cross-fades to `dungeon-drone.ogg` (or silence if absent); Settings → Music cycles volume/off.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit** — `git add` pom.xml, OggMusicBackend, SwingIO, .gitignore, docs/music.md; `git commit -m "feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Backlog doc
|
||||||
|
|
||||||
|
**Files:** Modify `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Under section **8. Musik (GUI)**, add a `> ✅ umgesetzt (Branch feature/music)` note mirroring the existing ✅ style: OGG Vorbis (vorbisspi/jorbis), per-room `music:` in rooms.yaml, external gitignored `music/` folder, idempotent track switch with ~400 ms fades, fade-to-silence in unscored rooms, Music level Off/Low/Med/High in Settings (default Medium), console silent; testable `MusicController` over an isolated `OggMusicBackend`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit** — `git commit -m "docs: mark GUI background music implemented"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
- [ ] `mvn -q clean test` — all tests pass (211 prior + MusicLevel/MusicController/RoomMusic/MusicSeam + extended Settings/Menu suites).
|
||||||
|
- [ ] OGG dependencies resolve; project compiles.
|
||||||
|
- [ ] Manual GUI smoke (optional, with an `.ogg`): room music plays/fades; Settings Music Off silences it; console run is silent.
|
||||||
|
- [ ] Load a saved game from before this feature → defaults to Medium, no crash.
|
||||||
|
- [ ] `git status` clean (no `music/` tracked).
|
||||||
|
```
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
# `go to <room>` Pathfinding Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add `go to <room>` — BFS auto-walk over visited rooms (respecting locked exits + darkness), routed through `GoCommand`, with a silent console summary and an animated GUI minimap.
|
||||||
|
|
||||||
|
**Architecture:** A new stateless `Pathfinder` (game package, next to `Conditions`/`Effects`/`Light`) does BFS over visited rooms using the same passability gates `GoCommand` enforces. `GoCommand` gains a branch: a directional arg moves one step (unchanged); a non-direction arg resolves a visited room by id/name and auto-walks the path. Per step it pushes a map frame via a new `GameIO.travelStep(MapView)` hook — a console no-op, overridden by `SwingIO` to repaint + briefly sleep so the minimap animates.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java` — BFS over visited rooms.
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `io/GameIO.java` — add `travelStep(MapView)` default no-op.
|
||||||
|
- `io/SwingIO.java` — override `travelStep` (setMap + ~300ms sleep).
|
||||||
|
- `command/impl/GoCommand.java` — extract `moveDirection`; add `goToRoom` (resolve + path + walk + summary).
|
||||||
|
- `docs/enhancement-ideas.md` — mark pathfinding implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `GameIO.travelStep` hook + `SwingIO` animation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java`
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TravelStepDefaultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultTravelStepIsSilentNoOp() {
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
// An empty map view is fine; the default must not print or throw.
|
||||||
|
io.travelStep(new MapView(List.of(), List.of()));
|
||||||
|
assertThat(io.outputs()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=TravelStepDefaultTest`
|
||||||
|
Expected: FAIL — `travelStep` is not defined on `GameIO`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the default to `GameIO`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/io/GameIO.java`, add the import (if missing it is already there — `MapView` is used by `setMap`) and the method:
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* One frame of an auto-walk (the {@code go to} command), as the player moves
|
||||||
|
* through a room en route. Default is a no-op: text mode travels silently.
|
||||||
|
* The GUI overrides this to repaint the map and pace the animation.
|
||||||
|
*
|
||||||
|
* @param view the map snapshot at this step
|
||||||
|
*/
|
||||||
|
default void travelStep(MapView view) {
|
||||||
|
// no animation in text mode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Override in `SwingIO`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/io/SwingIO.java`:
|
||||||
|
1. Add a constant near the other size constants (e.g. below `SMALL_SIZE`):
|
||||||
|
```java
|
||||||
|
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||||
|
private static final long TRAVEL_STEP_MS = 300L;
|
||||||
|
```
|
||||||
|
2. Add the override (place it near `setMap`):
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public void travelStep(MapView view) {
|
||||||
|
setMap(view);
|
||||||
|
try {
|
||||||
|
Thread.sleep(TRAVEL_STEP_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`setMap` already calls `map.show(view)` which repaints on the EDT; the sleep on the game-loop worker thread lets each frame render. `MapView` is already imported in `SwingIO`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=TravelStepDefaultTest`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/io/SwingIO.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/io/TravelStepDefaultTest.java
|
||||||
|
git commit -m "feat(io): travelStep hook for animated auto-walk (console no-op, Swing override)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `Pathfinder` BFS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/game/Pathfinder.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
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 thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PathfinderTest {
|
||||||
|
|
||||||
|
private Room room(String id) {
|
||||||
|
return new Room(id, id, "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameContext ctx(Room start, List<Room> all, Set<String> visited) {
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
for (Room r : all) {
|
||||||
|
rooms.put(r.getId(), r);
|
||||||
|
}
|
||||||
|
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
Player p = new Player(start, 0);
|
||||||
|
p.getVisitedRoomIds().addAll(visited);
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findsShortestPathOverVisitedRooms() {
|
||||||
|
Room a = room("a"), b = room("b"), c = room("c");
|
||||||
|
a.addExit(Direction.NORTH, b);
|
||||||
|
b.addExit(Direction.NORTH, c);
|
||||||
|
GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "b", "c"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, c)).containsExactly(b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void willNotRouteThroughUnvisitedRoom() {
|
||||||
|
Room a = room("a"), b = room("b"), c = room("c");
|
||||||
|
a.addExit(Direction.NORTH, b);
|
||||||
|
b.addExit(Direction.NORTH, c);
|
||||||
|
GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "c")); // b not visited
|
||||||
|
assertThat(Pathfinder.findPath(ctx, c)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void respectsLockedExit() {
|
||||||
|
Room a = room("a"), b = room("b");
|
||||||
|
a.addExit(Direction.NORTH, b);
|
||||||
|
a.addExitLock(Direction.NORTH,
|
||||||
|
new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "locked"));
|
||||||
|
GameContext ctx = ctx(a, List.of(a, b), Set.of("a", "b"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, b)).isEmpty();
|
||||||
|
ctx.getState().set("open");
|
||||||
|
assertThat(Pathfinder.findPath(ctx, b)).containsExactly(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void respectsDarkness() {
|
||||||
|
Room a = room("a");
|
||||||
|
Room dark = room("dark");
|
||||||
|
dark.setDark(true);
|
||||||
|
a.addExit(Direction.NORTH, dark);
|
||||||
|
GameContext ctx = ctx(a, List.of(a, dark), Set.of("a", "dark"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, dark)).isEmpty();
|
||||||
|
Item torch = PlainItem.builder().id("lit").name("Lit").description("d").light(true).build();
|
||||||
|
ctx.getPlayer().addItem(torch);
|
||||||
|
assertThat(Pathfinder.findPath(ctx, dark)).containsExactly(dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unreachableTargetReturnsEmpty() {
|
||||||
|
Room a = room("a");
|
||||||
|
Room island = room("island"); // no connecting exits
|
||||||
|
GameContext ctx = ctx(a, List.of(a, island), Set.of("a", "island"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, island)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=PathfinderTest`
|
||||||
|
Expected: FAIL — `Pathfinder` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `Pathfinder`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/game/Pathfinder.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadth-first shortest-path search over the rooms the player has already
|
||||||
|
* visited, honouring the same gates as a manual move: locked exits and
|
||||||
|
* darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/
|
||||||
|
* {@link Light}.
|
||||||
|
*/
|
||||||
|
public final class Pathfinder {
|
||||||
|
|
||||||
|
private Pathfinder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the shortest path from the player's current room to {@code target}.
|
||||||
|
*
|
||||||
|
* @param ctx active game context
|
||||||
|
* @param target destination room (expected to be a visited room)
|
||||||
|
* @return the rooms to step through, excluding the start and ending at
|
||||||
|
* {@code target}; empty if there is no currently-passable path
|
||||||
|
* (or {@code target} is the current room)
|
||||||
|
*/
|
||||||
|
public static List<Room> findPath(GameContext ctx, Room target) {
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Set<String> visited = ctx.getPlayer().getVisitedRoomIds();
|
||||||
|
|
||||||
|
Queue<Room> queue = new ArrayDeque<>();
|
||||||
|
Map<Room, Room> cameFrom = new HashMap<>();
|
||||||
|
queue.add(start);
|
||||||
|
cameFrom.put(start, null);
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
Room current = queue.poll();
|
||||||
|
if (current == target) {
|
||||||
|
return reconstruct(cameFrom, target);
|
||||||
|
}
|
||||||
|
for (Map.Entry<Direction, Room> exit : current.getExits().entrySet()) {
|
||||||
|
Room next = exit.getValue();
|
||||||
|
if (cameFrom.containsKey(next) || !visited.contains(next.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ExitLock lock = current.getExitLocks().get(exit.getKey());
|
||||||
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Light.isLit(ctx, next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cameFrom.put(next, current);
|
||||||
|
queue.add(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks predecessors back from target to start; returns steps after start. */
|
||||||
|
private static List<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
|
||||||
|
LinkedList<Room> path = new LinkedList<>();
|
||||||
|
for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) {
|
||||||
|
path.addFirst(r);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=PathfinderTest`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/game/Pathfinder.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/PathfinderTest.java
|
||||||
|
git commit -m "feat(game): Pathfinder BFS over visited rooms (locks + light aware)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `GoCommand` — route, resolve, walk, summarize
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GoCommandPathTest {
|
||||||
|
|
||||||
|
/** kitchen --N--> hallway --N--> library (all lit). Optional lock on kitchen->hallway. */
|
||||||
|
private GameContext world(boolean lockHallway, String libraryName) {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
Room library = new Room("library", libraryName, "d");
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
hallway.addExit(Direction.SOUTH, kitchen);
|
||||||
|
hallway.addExit(Direction.NORTH, library);
|
||||||
|
library.addExit(Direction.SOUTH, hallway);
|
||||||
|
if (lockHallway) {
|
||||||
|
kitchen.addExitLock(Direction.NORTH,
|
||||||
|
new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "It's locked."));
|
||||||
|
}
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
rooms.put("library", library);
|
||||||
|
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
Player p = new Player(kitchen, 0);
|
||||||
|
p.getVisitedRoomIds().addAll(List.of("kitchen", "hallway", "library"));
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String out(GameContext ctx) {
|
||||||
|
return ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goToWalksToVisitedRoomById() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("library"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library");
|
||||||
|
assertThat(out(ctx)).contains("make your way");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesByRoomName() {
|
||||||
|
// id "library" but type the display name (lower-cased) — still resolves
|
||||||
|
GameContext ctx = world(false, "Grand Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("grand", "library"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownPlaceReportsSo() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("dungeon"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||||
|
assertThat(out(ctx)).contains("don't know any place called 'dungeon'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alreadyThere() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("kitchen"));
|
||||||
|
assertThat(out(ctx)).contains("already in the Kitchen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noPassablePathReportsSo() {
|
||||||
|
GameContext ctx = world(true, "Library"); // kitchen->hallway locked, flag unset
|
||||||
|
new GoCommand().execute(ctx, List.of("library"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||||
|
assertThat(out(ctx)).contains("can't find a way there");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void directionalMoveStillWorks() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("north"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("hallway");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=GoCommandPathTest`
|
||||||
|
Expected: FAIL — `go to` routing not implemented (e.g. `goToWalksToVisitedRoomById` fails: "library" treated as an unknown direction).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite `GoCommand`**
|
||||||
|
|
||||||
|
Replace `src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
import thb.jeanluc.adventure.game.Pathfinder;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the player. {@code go <direction>} steps once to a connected room.
|
||||||
|
* {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
|
||||||
|
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
|
||||||
|
* {@code go to <room>}.
|
||||||
|
*/
|
||||||
|
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' or 'go to <room>'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Direction dir = Direction.fromString(args.getFirst());
|
||||||
|
moveDirection(ctx, dir);
|
||||||
|
} catch (IllegalArgumentException notADirection) {
|
||||||
|
goToRoom(ctx, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-step move in a compass direction (the original behaviour). */
|
||||||
|
private void moveDirection(GameContext ctx, Direction dir) {
|
||||||
|
Room here = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Optional<Room> next = here.getExit(dir);
|
||||||
|
if (next.isEmpty()) {
|
||||||
|
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ExitLock lock = here.getExitLocks().get(dir);
|
||||||
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
|
ctx.getIo().write(lock.blocked());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Light.isLit(ctx, next.get())) {
|
||||||
|
ctx.getIo().write("It's pitch black beyond the doorway — you need a lit light source "
|
||||||
|
+ "(try lighting your lamp with 'use lamp').");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.getPlayer().setCurrentRoom(next.get());
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
|
||||||
|
private void goToRoom(GameContext ctx, List<String> args) {
|
||||||
|
String name = String.join(" ", args).toLowerCase();
|
||||||
|
Room target = resolveVisited(ctx, name);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.getIo().write("You don't know any place called '" + name + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
if (target == start) {
|
||||||
|
ctx.getIo().write("You're already in the " + target.getName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> path = Pathfinder.findPath(ctx, target);
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
ctx.getIo().write("You can't find a way there right now.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> route = new ArrayList<>();
|
||||||
|
route.add(start);
|
||||||
|
route.addAll(path);
|
||||||
|
for (Room step : path) {
|
||||||
|
ctx.getPlayer().setCurrentRoom(step);
|
||||||
|
ctx.getIo().travelStep(MapLayout.compute(
|
||||||
|
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
|
||||||
|
}
|
||||||
|
ctx.getIo().write(summary(route));
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds a visited room whose id or display name matches {@code name}. */
|
||||||
|
private Room resolveVisited(GameContext ctx, String name) {
|
||||||
|
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
|
||||||
|
Room r = ctx.getWorld().getRooms().get(id);
|
||||||
|
if (r == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.equals(name) || r.getName().toLowerCase().equals(name)) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names start, destination, and at most one middle waypoint. */
|
||||||
|
private String summary(List<Room> route) {
|
||||||
|
int n = route.size();
|
||||||
|
String start = route.get(0).getName();
|
||||||
|
String dest = route.get(n - 1).getName();
|
||||||
|
if (n >= 3) {
|
||||||
|
String middle = route.get(n / 2).getName();
|
||||||
|
return "You make your way from the " + start + ", through the " + middle
|
||||||
|
+ ", to the " + dest + ".";
|
||||||
|
}
|
||||||
|
return "You make your way from the " + start + " to the " + dest + ".";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String help() {
|
||||||
|
return "go <direction> - move one room (north/south/east/west); "
|
||||||
|
+ "or go to <room> to travel to a known room";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=GoCommandPathTest`
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full suite (no regressions to existing go/movement tests)**
|
||||||
|
|
||||||
|
Run: `mvn -q clean test`
|
||||||
|
Expected: BUILD SUCCESS, all green (existing `GameTest`/command tests that use `go north` still pass).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/command/impl/GoCommand.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/command/impl/GoCommandPathTest.java
|
||||||
|
git commit -m "feat(command): go to <room> auto-walk (BFS path, animated map, summary)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mark pathfinding implemented**
|
||||||
|
|
||||||
|
In `docs/enhancement-ideas.md`, find the "Festgelegte Erweiterungs-Entscheidungen"
|
||||||
|
area where the blockquote notes Pathfinding is *in scope for a later round* (added
|
||||||
|
when main-menu/save-load shipped). Update that note to mark it done, in the existing
|
||||||
|
`> ✅` style:
|
||||||
|
|
||||||
|
> ✅ umgesetzt (Branch `feature/pathfinding-go-to`): `go to <raum>` per BFS über
|
||||||
|
> besuchte Räume (Locks + Licht beachtet), via `GoCommand` (Richtung vs. Raumziel).
|
||||||
|
> Konsole still + Zusammenfassung (Start/Mitte/Ziel), GUI-Minikarte animiert pro
|
||||||
|
> Schritt (`GameIO.travelStep`, ~300 ms). Trie-Autocomplete weiter zurückgestellt.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/enhancement-ideas.md
|
||||||
|
git commit -m "docs: mark go to <room> pathfinding implemented"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
- [ ] `mvn -q clean test` — all tests pass (184 prior + new Pathfinder/GoCommand/travelStep suites).
|
||||||
|
- [ ] Manual GUI smoke (optional): start a game, walk kitchen→hallway→library manually (so they're visited), return to kitchen, then `go to library` — the minimap dot should step through the hallway to the library (~300ms each), then the library room renders with a summary line.
|
||||||
|
- [ ] Manual console smoke (optional): same, confirming silent traversal + the "You make your way ..." line + destination look.
|
||||||
|
- [ ] `git status` clean.
|
||||||
822
Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md
Normal file
822
Semesterprojekt/docs/superpowers/plans/2026-06-01-tutorial.md
Normal file
@@ -0,0 +1,822 @@
|
|||||||
|
# Interactive Start-of-Game Tutorial Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an interactive, skippable start-of-game walkthrough that makes the player perform the core commands (look→examine→take→inventory→use→go→go-to) in the real starting room, then lists the rest, shown only on New Game.
|
||||||
|
|
||||||
|
**Architecture:** A stateful `TutorialGuide` (game package) is layered onto the real `Game` loop (optional; null in normal/loaded play). It prints an instruction, and after each executed command checks — via alias-aware registry lookup, with movement steps requiring an actual room change — whether the step is satisfied (confirm + advance) or not (gentle hint). Steps come from an optional `tutorial.yaml` loaded by a standalone `TutorialLoader` into a `Tutorial`/`TutorialStep` model. `skip` ends it. `App` wires the guide only for New Game.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, Jackson (YAML), JUnit 5 + AssertJ, Lombok.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `model/Tutorial.java`, `model/TutorialStep.java` — domain records.
|
||||||
|
- `loader/dto/TutorialDto.java`, `loader/dto/TutorialStepDto.java` — YAML DTOs.
|
||||||
|
- `loader/TutorialLoader.java` — reads optional `/world/tutorial.yaml` → `Tutorial`.
|
||||||
|
- `game/TutorialGuide.java` — the guide.
|
||||||
|
- `src/main/resources/world/tutorial.yaml` — intro + 7 steps + closing tips.
|
||||||
|
- `src/test/resources/world/tutorial.yaml` — small 2-step loader fixture.
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `game/Game.java` — optional guide field + `setTutorialGuide` + loop hooks.
|
||||||
|
- `App.java` — `play(..., withTutorial)`, build guide on New Game.
|
||||||
|
- `docs/enhancement-ideas.md` — note the tutorial.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Tutorial model, DTO, loader, content
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/model/Tutorial.java`
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/model/TutorialStep.java`
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java`
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java`
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java`
|
||||||
|
- Create: `src/main/resources/world/tutorial.yaml`
|
||||||
|
- Create: `src/test/resources/world/tutorial.yaml`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TutorialLoaderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadsTheTestFixture() {
|
||||||
|
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
|
||||||
|
assertThat(t.isEmpty()).isFalse();
|
||||||
|
assertThat(t.intro()).isNotBlank();
|
||||||
|
assertThat(t.closingTips()).isNotBlank();
|
||||||
|
assertThat(t.steps()).hasSize(2);
|
||||||
|
assertThat(t.steps().getFirst().expect()).isEqualTo("look");
|
||||||
|
assertThat(t.steps().getFirst().minArgs()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingFileReturnsEmptyTutorial() {
|
||||||
|
Tutorial t = new TutorialLoader("/no-such-base").load();
|
||||||
|
assertThat(t.isEmpty()).isTrue();
|
||||||
|
assertThat(t.steps()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=TutorialLoaderTest`
|
||||||
|
Expected: FAIL — `Tutorial`/`TutorialLoader` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the model records**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/model/TutorialStep.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One interactive tutorial step: an instruction, the command that satisfies it
|
||||||
|
* ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"),
|
||||||
|
* a minimum argument count, a success confirmation, and a hint for a valid but
|
||||||
|
* wrong command.
|
||||||
|
*/
|
||||||
|
public record TutorialStep(
|
||||||
|
String instruction,
|
||||||
|
String expect,
|
||||||
|
int minArgs,
|
||||||
|
String confirm,
|
||||||
|
String hint
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/model/Tutorial.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */
|
||||||
|
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
|
||||||
|
|
||||||
|
public Tutorial {
|
||||||
|
steps = steps == null ? List.of() : List.copyOf(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return steps.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An absent tutorial (no steps). */
|
||||||
|
public static Tutorial none() {
|
||||||
|
return new Tutorial(null, List.of(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create the DTOs**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
/** YAML representation of a tutorial step. */
|
||||||
|
public record TutorialStepDto(
|
||||||
|
String instruction,
|
||||||
|
String expect,
|
||||||
|
Integer minArgs,
|
||||||
|
String confirm,
|
||||||
|
String hint
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of the tutorial document. */
|
||||||
|
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create `TutorialLoader`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.TutorialDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.TutorialStepDto;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the optional onboarding tutorial from {@code <base>/tutorial.yaml}.
|
||||||
|
* Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world
|
||||||
|
* content. Returns {@link Tutorial#none()} when the file is absent.
|
||||||
|
*/
|
||||||
|
public class TutorialLoader {
|
||||||
|
|
||||||
|
/** Default classpath base directory. */
|
||||||
|
public static final String DEFAULT_BASE = "/world";
|
||||||
|
|
||||||
|
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||||
|
private final String basePath;
|
||||||
|
|
||||||
|
public TutorialLoader() {
|
||||||
|
this(DEFAULT_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TutorialLoader(String basePath) {
|
||||||
|
this.basePath = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
|
||||||
|
public Tutorial load() {
|
||||||
|
String resource = basePath + "/tutorial.yaml";
|
||||||
|
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
||||||
|
if (in == null) {
|
||||||
|
return Tutorial.none();
|
||||||
|
}
|
||||||
|
TutorialDto dto = yaml.readValue(in, TutorialDto.class);
|
||||||
|
if (dto == null) {
|
||||||
|
return Tutorial.none();
|
||||||
|
}
|
||||||
|
List<TutorialStep> steps = new ArrayList<>();
|
||||||
|
if (dto.steps() != null) {
|
||||||
|
for (TutorialStepDto s : dto.steps()) {
|
||||||
|
steps.add(new TutorialStep(
|
||||||
|
s.instruction(),
|
||||||
|
s.expect(),
|
||||||
|
s.minArgs() == null ? 0 : s.minArgs(),
|
||||||
|
s.confirm(),
|
||||||
|
s.hint()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Tutorial(dto.intro(), steps, dto.closingTips());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create the production `tutorial.yaml`**
|
||||||
|
|
||||||
|
`src/main/resources/world/tutorial.yaml`:
|
||||||
|
```yaml
|
||||||
|
intro: |
|
||||||
|
Welcome to the manor. Let me show you the ropes — just type the commands as
|
||||||
|
I describe them. (Type 'skip' at any time to jump straight into the game.)
|
||||||
|
steps:
|
||||||
|
- instruction: "First, look around the room: type 'look'."
|
||||||
|
expect: look
|
||||||
|
confirm: "Good — 'look' (or 'l') re-describes wherever you are."
|
||||||
|
hint: "When you're ready, just type: look"
|
||||||
|
- instruction: "Examine something you noticed: 'examine <thing>' (or 'x <thing>')."
|
||||||
|
expect: examine
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "That's how you inspect items, exits, and people up close."
|
||||||
|
hint: "Try examining something, e.g.: examine lamp"
|
||||||
|
- instruction: "Pick something up: 'take <item>'."
|
||||||
|
expect: take
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Taken. Things you carry go into your inventory."
|
||||||
|
hint: "Pick something up, e.g.: take lamp"
|
||||||
|
- instruction: "Check what you're carrying: 'inventory' (or 'i')."
|
||||||
|
expect: inventory
|
||||||
|
confirm: "That's your inventory."
|
||||||
|
hint: "Type: inventory"
|
||||||
|
- instruction: "Use an item: 'use <item>' — try lighting the lamp."
|
||||||
|
expect: use
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Some items toggle, read, or react when you use them."
|
||||||
|
hint: "Use something, e.g.: use lamp"
|
||||||
|
- instruction: "Now move to another room: 'go <direction>' (try 'go north')."
|
||||||
|
expect: go_direction
|
||||||
|
confirm: "You can move north / south / east / west between connected rooms."
|
||||||
|
hint: "Head to a connected room, e.g.: go north"
|
||||||
|
- instruction: "Travel back to a room you've already seen: 'go to <room>'."
|
||||||
|
expect: go_to_room
|
||||||
|
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
|
||||||
|
hint: "Try: go to <a room you've already visited>"
|
||||||
|
closingTips: |
|
||||||
|
That's the core. A few more you'll want:
|
||||||
|
read · drop · talk to <npc> · give <item> to <npc> ·
|
||||||
|
use <item> on <item> (combine things) · map · quests · save · menu.
|
||||||
|
Type 'help' anytime. Good luck.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Create the test fixture `tutorial.yaml`**
|
||||||
|
|
||||||
|
`src/test/resources/world/tutorial.yaml` (small, deterministic for the loader test):
|
||||||
|
```yaml
|
||||||
|
intro: |
|
||||||
|
Test intro.
|
||||||
|
steps:
|
||||||
|
- instruction: "Look: type 'look'."
|
||||||
|
expect: look
|
||||||
|
confirm: "Looked."
|
||||||
|
hint: "Type look."
|
||||||
|
- instruction: "Take: type 'take <item>'."
|
||||||
|
expect: take
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Took it."
|
||||||
|
hint: "Take something."
|
||||||
|
closingTips: |
|
||||||
|
Test tips.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=TutorialLoaderTest`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/Tutorial.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/model/TutorialStep.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/dto/TutorialDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/dto/TutorialStepDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/TutorialLoader.java \
|
||||||
|
src/main/resources/world/tutorial.yaml \
|
||||||
|
src/test/resources/world/tutorial.yaml \
|
||||||
|
src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java
|
||||||
|
git commit -m "feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `TutorialGuide`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java`:
|
||||||
|
```java
|
||||||
|
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.ParsedCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.ExamineCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.GoCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.UseCommand;
|
||||||
|
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 thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TutorialGuideTest {
|
||||||
|
|
||||||
|
private CommandRegistry registry() {
|
||||||
|
CommandRegistry r = new CommandRegistry();
|
||||||
|
r.register(new LookCommand(), "look", "l");
|
||||||
|
r.register(new ExamineCommand(), "examine", "x");
|
||||||
|
r.register(new TakeCommand(), "take", "get");
|
||||||
|
r.register(new InventoryCommand(), "inventory", "i");
|
||||||
|
r.register(new UseCommand(), "use");
|
||||||
|
r.register(new GoCommand(), "go", "move", "walk");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
private final Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
|
||||||
|
private GameContext ctx() {
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
Player p = new Player(kitchen, 0);
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParsedCommand parse(String s) {
|
||||||
|
return new CommandParser().parse(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String out(GameContext ctx) {
|
||||||
|
return ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TutorialStep step(String instr, String expect, int minArgs) {
|
||||||
|
return new TutorialStep(instr, expect, minArgs, "OK:" + expect, "HINT:" + expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void beginPrintsIntroAndFirstInstruction() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
assertThat(out(ctx)).contains("INTRO").contains("do look");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aliasSatisfiesVerbStepAndAdvancesToTips() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.onCommand(ctx, parse("l")); // alias of look
|
||||||
|
assertThat(out(ctx)).contains("OK:look").contains("TIPS");
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void minArgsGatesTakeStep() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("take it", "take", 1)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.onCommand(ctx, parse("take")); // no arg -> hint, not satisfied
|
||||||
|
assertThat(out(ctx)).contains("HINT:take");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
g.onCommand(ctx, parse("take lamp")); // satisfied
|
||||||
|
assertThat(out(ctx)).contains("OK:take");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goDirectionRequiresAnActualMove() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("move", "go_direction", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
// typed a direction but did NOT move -> hint
|
||||||
|
g.onCommand(ctx, parse("go north"));
|
||||||
|
assertThat(out(ctx)).contains("HINT:go_direction");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
// now actually move, then the same command satisfies
|
||||||
|
ctx.getPlayer().setCurrentRoom(hallway);
|
||||||
|
g.onCommand(ctx, parse("go north"));
|
||||||
|
assertThat(out(ctx)).contains("OK:go_direction");
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goToRoomNeedsNonDirectionArgAndMove() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
ctx.getPlayer().setCurrentRoom(hallway); // moved
|
||||||
|
g.onCommand(ctx, parse("go kitchen")); // "kitchen" is not a direction
|
||||||
|
assertThat(out(ctx)).contains("OK:go_to_room");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipDeactivates() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("do look", "look", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.skip(ctx);
|
||||||
|
assertThat(out(ctx)).contains("Tutorial skipped");
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyTutorialIsInactive() {
|
||||||
|
TutorialGuide g = new TutorialGuide(Tutorial.none(), registry());
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=TutorialGuideTest`
|
||||||
|
Expected: FAIL — `TutorialGuide` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `TutorialGuide`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||||
|
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the interactive start-of-game walkthrough, layered onto the real game
|
||||||
|
* loop. Prints an instruction, then on each executed command decides whether the
|
||||||
|
* current step is satisfied (confirm + advance) or not (hint). Movement steps
|
||||||
|
* require an actual room change. Alias-aware via the {@link CommandRegistry}.
|
||||||
|
*/
|
||||||
|
public final class TutorialGuide {
|
||||||
|
|
||||||
|
private final Tutorial tutorial;
|
||||||
|
private final CommandRegistry registry;
|
||||||
|
private int index;
|
||||||
|
private boolean active;
|
||||||
|
private Room roomAtStepStart;
|
||||||
|
|
||||||
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
|
this.tutorial = tutorial;
|
||||||
|
this.registry = registry;
|
||||||
|
this.active = !tutorial.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isActive() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the intro and the first instruction (call once at loop start). */
|
||||||
|
public void begin(GameContext ctx) {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tutorial.intro() != null && !tutorial.intro().isBlank()) {
|
||||||
|
ctx.getIo().write(tutorial.intro());
|
||||||
|
}
|
||||||
|
enterStep(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluates the current step against the command that was just executed. */
|
||||||
|
public void onCommand(GameContext ctx, ParsedCommand parsed) {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TutorialStep step = tutorial.steps().get(index);
|
||||||
|
if (satisfies(step, ctx, parsed)) {
|
||||||
|
ctx.getIo().write(step.confirm());
|
||||||
|
index++;
|
||||||
|
if (index >= tutorial.steps().size()) {
|
||||||
|
if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) {
|
||||||
|
ctx.getIo().write(tutorial.closingTips());
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
} else {
|
||||||
|
enterStep(ctx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.getIo().write(step.hint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the tutorial early (the 'skip' command). */
|
||||||
|
public void skip(GameContext ctx) {
|
||||||
|
if (active) {
|
||||||
|
ctx.getIo().write("Tutorial skipped.");
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enterStep(GameContext ctx) {
|
||||||
|
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
||||||
|
ctx.getIo().write(tutorial.steps().get(index).instruction());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
|
||||||
|
String expect = step.expect();
|
||||||
|
if ("go_direction".equals(expect)) {
|
||||||
|
return isGo(parsed) && firstIsDirection(parsed) && moved(ctx);
|
||||||
|
}
|
||||||
|
if ("go_to_room".equals(expect)) {
|
||||||
|
return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx);
|
||||||
|
}
|
||||||
|
return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGo(ParsedCommand p) {
|
||||||
|
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean firstIsDirection(ParsedCommand p) {
|
||||||
|
if (p.args().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Direction.fromString(p.args().get(0));
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean moved(GameContext ctx) {
|
||||||
|
return ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameCommand(String a, String b) {
|
||||||
|
Optional<Command> ca = registry.find(a);
|
||||||
|
Optional<Command> cb = registry.find(b);
|
||||||
|
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=TutorialGuideTest`
|
||||||
|
Expected: PASS (7 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/game/TutorialGuide.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/TutorialGuideTest.java
|
||||||
|
git commit -m "feat(game): TutorialGuide interactive walkthrough engine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Wire the guide into `Game` and `App`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java`
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/App.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java`:
|
||||||
|
```java
|
||||||
|
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.LookCommand;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameTutorialTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void guideBeginsAndAdvancesThenSkipEndsViaLoop() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(), "t", "w");
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
// scripted player input: a matching 'look', then 'skip'; then EOF (null) ends run()
|
||||||
|
io.enqueue("look").enqueue("skip");
|
||||||
|
GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io);
|
||||||
|
|
||||||
|
CommandRegistry registry = new CommandRegistry();
|
||||||
|
registry.register(new LookCommand(), "look", "l");
|
||||||
|
|
||||||
|
Tutorial t = new Tutorial("INTRO",
|
||||||
|
List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint"),
|
||||||
|
new TutorialStep("do look again", "look", 0, "STEP2-OK", "hint")),
|
||||||
|
"TIPS");
|
||||||
|
Game game = new Game(ctx, registry, new CommandParser());
|
||||||
|
game.setTutorialGuide(new TutorialGuide(t, registry));
|
||||||
|
|
||||||
|
game.run();
|
||||||
|
|
||||||
|
String out = io.allOutput();
|
||||||
|
assertThat(out).contains("INTRO"); // begin printed
|
||||||
|
assertThat(out).contains("STEP1-OK"); // first 'look' advanced step 1
|
||||||
|
assertThat(out).contains("Tutorial skipped"); // 'skip' ended it before step 2 finished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=GameTutorialTest`
|
||||||
|
Expected: FAIL — `Game.setTutorialGuide` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the guide hooks to `Game`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/game/Game.java`:
|
||||||
|
|
||||||
|
1. Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||||
|
```
|
||||||
|
(Same package — skip the import; reference `TutorialGuide` directly.)
|
||||||
|
|
||||||
|
2. Add a field after `private boolean running = true;`:
|
||||||
|
```java
|
||||||
|
/** Optional onboarding guide; null in normal/loaded play. */
|
||||||
|
private TutorialGuide tutorialGuide;
|
||||||
|
|
||||||
|
/** Wires the interactive tutorial (New Game only). */
|
||||||
|
public void setTutorialGuide(TutorialGuide tutorialGuide) {
|
||||||
|
this.tutorialGuide = tutorialGuide;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `run()`, after the initial `publishHud(); maybeEnd();` and before `while (running) {`:
|
||||||
|
```java
|
||||||
|
if (tutorialGuide != null) {
|
||||||
|
tutorialGuide.begin(ctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Inside the loop, replace the body from the `parsed.verb().isEmpty()` check through the dispatch with this (adds the skip interception and the post-command hook):
|
||||||
|
```java
|
||||||
|
ParsedCommand parsed = parser.parse(input);
|
||||||
|
if (parsed.verb().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()
|
||||||
|
&& parsed.verb().equals("skip")) {
|
||||||
|
tutorialGuide.skip(ctx);
|
||||||
|
publishHud();
|
||||||
|
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());
|
||||||
|
ctx.getSession().incrementTurn();
|
||||||
|
if (ctx.getSession().getTurn() % 10 == 0) {
|
||||||
|
ctx.save();
|
||||||
|
}
|
||||||
|
if (tutorialGuide != null) {
|
||||||
|
tutorialGuide.onCommand(ctx, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishHud();
|
||||||
|
maybeEnd();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire `App`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/App.java`:
|
||||||
|
|
||||||
|
1. Add imports:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||||
|
import thb.jeanluc.adventure.loader.TutorialLoader;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Change the two `play(...)` call sites in `run()`:
|
||||||
|
```java
|
||||||
|
case NEW_GAME -> play(io, saves, newSession(io), true);
|
||||||
|
```
|
||||||
|
and inside the LOAD branch:
|
||||||
|
```java
|
||||||
|
play(io, saves, saves.load(slot.slug()), false);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Change the `play` signature and add the guide wiring (after the `HelpCommand` is registered, before the banner):
|
||||||
|
```java
|
||||||
|
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
|
||||||
|
```
|
||||||
|
and, immediately after `registry.register(new HelpCommand(registry), "help", "?");`:
|
||||||
|
```java
|
||||||
|
if (withTutorial) {
|
||||||
|
Tutorial tutorial = new TutorialLoader().load();
|
||||||
|
if (!tutorial.isEmpty()) {
|
||||||
|
game.setTutorialGuide(new TutorialGuide(tutorial, registry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests + full suite**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=GameTutorialTest`
|
||||||
|
Expected: PASS.
|
||||||
|
Run: `mvn -q clean test`
|
||||||
|
Expected: BUILD SUCCESS, all green (existing GameTest / loop tests unaffected — guide is null there).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Manual console smoke (optional)**
|
||||||
|
|
||||||
|
Run: `mvn -q -DskipTests compile dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt >/dev/null 2>&1` then
|
||||||
|
`printf '1\nHero\nlook\nskip\nquit\n4\n' | java -cp "target/classes:$(cat /tmp/cp.txt)" thb.jeanluc.adventure.App`
|
||||||
|
Expected: New Game → name → room shown → tutorial intro + "look" instruction → after `look`, confirmation + next instruction → `skip` → "Tutorial skipped." → normal play → `quit` → menu → Quit.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/game/Game.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/App.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/GameTutorialTest.java
|
||||||
|
git commit -m "feat: wire TutorialGuide into the game loop; show on New Game only"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a tutorial note**
|
||||||
|
|
||||||
|
In `docs/enhancement-ideas.md`, under the "Festgelegte Erweiterungs-Entscheidungen"
|
||||||
|
area (near the pathfinding ✅ note), add:
|
||||||
|
|
||||||
|
> ✅ **Tutorial** umgesetzt (Branch `feature/tutorial`): interaktiver Walkthrough
|
||||||
|
> beim Neuen Spiel (look→examine→take→inventory→use→go→go to), verb-/alias-basierte
|
||||||
|
> Abschluss-Erkennung (Bewegungs-Schritte verlangen echten Raumwechsel), Abschluss-
|
||||||
|
> Tipps, jederzeit per `skip` abbrechbar. Datengetrieben (`tutorial.yaml`,
|
||||||
|
> `TutorialLoader`), als `TutorialGuide` in den Game-Loop eingehängt; nur bei Neuem
|
||||||
|
> Spiel, nicht beim Laden.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/enhancement-ideas.md
|
||||||
|
git commit -m "docs: note interactive start-of-game tutorial"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
- [ ] `mvn -q clean test` — all tests pass (197 prior + TutorialLoader/TutorialGuide/GameTutorial suites).
|
||||||
|
- [ ] Manual console smoke (Task 3 Step 6) behaves as described.
|
||||||
|
- [ ] Manual: Load a saved game → NO tutorial appears.
|
||||||
|
- [ ] `git status` clean.
|
||||||
|
```
|
||||||
957
Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md
Normal file
957
Semesterprojekt/docs/superpowers/plans/2026-06-01-use-x-on-y.md
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
# `use X on Y` Item Combinations Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a data-driven `use X on Y` item-combination mechanic: recipes that optionally require conditions, consume items, produce an item, apply effects, and print a response — operands resolved from inventory or the current room.
|
||||||
|
|
||||||
|
**Architecture:** A new `Combination` domain record + optional `combinations.yaml` loaded like quests/endings into `World.combinations` (a `Map` keyed by an order-independent pair key). A stateless `Combinations` engine (alongside `Conditions`/`Effects`/`Light`) performs resolution, lookup, and application. `UseCommand` routes 2+ args to the engine; 1 arg keeps the existing single-item behaviour. No parser change — `on`/`with`/`to` are already filler words.
|
||||||
|
|
||||||
|
**Tech Stack:** Java 25, Maven, Jackson (YAML loader), JUnit 5 + AssertJ, Lombok.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/model/Combination.java` — domain record + canonical pair key.
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java` — YAML DTO.
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java` — DTO → domain, validates item ids exist.
|
||||||
|
- `src/main/java/thb/jeanluc/adventure/game/Combinations.java` — combination engine.
|
||||||
|
- `src/main/resources/world/combinations.yaml` — demo recipe.
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `model/World.java` — add `combinations` field + backward-compatible constructors + getter.
|
||||||
|
- `loader/WorldLoader.java` — read `combinations.yaml`, build the map (dup-key check), pass to `World`.
|
||||||
|
- `command/impl/UseCommand.java` — route 2+ args to `Combinations.tryUse`.
|
||||||
|
- `src/main/resources/world/items.yaml` — add `matches`, `torch`, `lit_torch`.
|
||||||
|
- `src/main/resources/world/rooms.yaml` — place `matches` (library) and `torch` (hallway).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Combination` domain model + canonical key
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/model/Combination.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/model/CombinationTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CombinationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyIsOrderIndependentAndSorted() {
|
||||||
|
assertThat(Combination.key("matches", "torch"))
|
||||||
|
.isEqualTo(Combination.key("torch", "matches"))
|
||||||
|
.isEqualTo("matches|torch");
|
||||||
|
assertThat(Combination.key("b", "a")).isEqualTo("a|b");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullCollectionComponentsBecomeEmpty() {
|
||||||
|
Combination c = new Combination("a", "b", null, null, null, null, "ok", null);
|
||||||
|
assertThat(c.requires()).isEmpty();
|
||||||
|
assertThat(c.consume()).isEmpty();
|
||||||
|
assertThat(c.effects()).isEmpty();
|
||||||
|
assertThat(c.produce()).isNull();
|
||||||
|
assertThat(c.key()).isEqualTo("a|b");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationTest`
|
||||||
|
Expected: FAIL — `Combination` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `Combination`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/model/Combination.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data-driven item-combination recipe. Identified by an unordered pair of
|
||||||
|
* item ids ({@code a}, {@code b}). When applied it may require conditions,
|
||||||
|
* consume items, produce an item, apply effects, and print a response.
|
||||||
|
*/
|
||||||
|
public record Combination(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
List<Condition> requires,
|
||||||
|
List<String> consume,
|
||||||
|
String produce,
|
||||||
|
List<Effect> effects,
|
||||||
|
String response,
|
||||||
|
String failText
|
||||||
|
) {
|
||||||
|
public Combination {
|
||||||
|
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||||
|
consume = consume == null ? List.of() : List.copyOf(consume);
|
||||||
|
effects = effects == null ? List.of() : List.copyOf(effects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Order-independent key for an item pair (sorted, '|'-joined). */
|
||||||
|
public static String key(String x, String y) {
|
||||||
|
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This recipe's canonical pair key. */
|
||||||
|
public String key() {
|
||||||
|
return key(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationTest`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/Combination.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/model/CombinationTest.java
|
||||||
|
git commit -m "feat(model): Combination recipe record + order-independent key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `World.combinations` field + constructors
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/model/World.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class WorldCombinationsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyConstructorsDefaultToEmptyCombinations() {
|
||||||
|
World w5 = new World(Map.of(), Map.of(), Map.of(), "t", "w");
|
||||||
|
World w6 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of());
|
||||||
|
World w7 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), List.of());
|
||||||
|
assertThat(w5.getCombinations()).isEmpty();
|
||||||
|
assertThat(w6.getCombinations()).isEmpty();
|
||||||
|
assertThat(w7.getCombinations()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullConstructorStoresCombinations() {
|
||||||
|
Combination c = new Combination("a", "b", List.of(), List.of(), null, List.of(), "r", null);
|
||||||
|
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w",
|
||||||
|
Map.of(), List.of(), Map.of(c.key(), c));
|
||||||
|
assertThat(w.getCombinations()).containsKey("a|b");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=WorldCombinationsTest`
|
||||||
|
Expected: FAIL — no `getCombinations()` / no 8-arg constructor.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the field, constructors, and import**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/model/World.java`:
|
||||||
|
|
||||||
|
Add the import near the other model imports:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
```
|
||||||
|
(If the file is in package `thb.jeanluc.adventure.model`, `Combination` is same-package — skip the import; reference it directly.)
|
||||||
|
|
||||||
|
Add the field immediately after the `endings` field:
|
||||||
|
```java
|
||||||
|
/** Global lookup of combination recipes by canonical pair key. */
|
||||||
|
private final Map<String, Combination> combinations;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the existing 5-arg and 6-arg convenience constructors and add a 7-arg one, so all delegate to the Lombok-generated 8-arg constructor:
|
||||||
|
```java
|
||||||
|
/** Backward-compatible constructor for worlds without quests, endings, or combinations. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds with quests but no endings/combinations. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage, Map<String, Quest> quests) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, quests, List.of(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds with quests + endings but no combinations. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage, Map<String, Quest> quests, List<Ending> endings) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, quests, endings, Map.of());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(The `@RequiredArgsConstructor` now generates the canonical 8-arg constructor `(rooms, items, npcs, title, welcomeMessage, quests, endings, combinations)`, and `@Getter` provides `getCombinations()`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=WorldCombinationsTest,QuestEngineTest`
|
||||||
|
Expected: PASS (QuestEngineTest uses the 6-arg constructor — still compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Full compile check (all World callers still build)**
|
||||||
|
|
||||||
|
Run: `mvn -q -DskipTests test-compile`
|
||||||
|
Expected: BUILD SUCCESS (5/6/7-arg call sites in tests + WorldLoader still resolve).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/model/World.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/model/WorldCombinationsTest.java
|
||||||
|
git commit -m "feat(model): World.combinations field + backward-compatible constructors"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `CombinationDto` + `CombinationFactory` (with id validation)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`
|
||||||
|
|
||||||
|
Validation lives in the factory (build-time, with the item map at hand) rather than `WorldValidator`, to avoid changing `WorldValidator`'s constructor (used directly by `WorldValidatorTest`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
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 CombinationFactoryTest {
|
||||||
|
|
||||||
|
private Map<String, Item> items() {
|
||||||
|
Map<String, Item> m = new HashMap<>();
|
||||||
|
for (String id : List.of("matches", "torch", "lit_torch")) {
|
||||||
|
m.put(id, PlainItem.builder().id(id).name(id).description("d").light(false).build());
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsCombinationFromDto() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "torch", null,
|
||||||
|
List.of("matches", "torch"), "lit_torch", null, "You light it.", null);
|
||||||
|
Combination c = CombinationFactory.fromDto(dto, items());
|
||||||
|
assertThat(c.key()).isEqualTo("matches|torch");
|
||||||
|
assertThat(c.consume()).containsExactly("matches", "torch");
|
||||||
|
assertThat(c.produce()).isEqualTo("lit_torch");
|
||||||
|
assertThat(c.response()).isEqualTo("You light it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownOperandId() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "ghost", null, null, null, null, "x", null);
|
||||||
|
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
|
||||||
|
.isInstanceOf(WorldLoadException.class)
|
||||||
|
.hasMessageContaining("ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownProduceId() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "torch", null, null, "phantom", null, "x", null);
|
||||||
|
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
|
||||||
|
.isInstanceOf(WorldLoadException.class)
|
||||||
|
.hasMessageContaining("phantom");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationFactoryTest`
|
||||||
|
Expected: FAIL — `CombinationDto` / `CombinationFactory` do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `CombinationDto`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of an item-combination recipe. */
|
||||||
|
public record CombinationDto(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
List<ConditionDto> requires,
|
||||||
|
List<String> consume,
|
||||||
|
String produce,
|
||||||
|
List<EffectDto> effects,
|
||||||
|
String response,
|
||||||
|
String failText
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `CombinationFactory`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EffectDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
|
||||||
|
public final class CombinationFactory {
|
||||||
|
|
||||||
|
private CombinationFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
|
||||||
|
requireItem(items, dto.a(), "a");
|
||||||
|
requireItem(items, dto.b(), "b");
|
||||||
|
if (dto.consume() != null) {
|
||||||
|
for (String id : dto.consume()) {
|
||||||
|
requireItem(items, id, "consume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.produce() != null) {
|
||||||
|
requireItem(items, dto.produce(), "produce");
|
||||||
|
}
|
||||||
|
return new Combination(
|
||||||
|
dto.a(),
|
||||||
|
dto.b(),
|
||||||
|
ConditionDto.toModelList(dto.requires()),
|
||||||
|
dto.consume() == null ? List.of() : List.copyOf(dto.consume()),
|
||||||
|
dto.produce(),
|
||||||
|
EffectDto.toModelList(dto.effects()),
|
||||||
|
dto.response(),
|
||||||
|
dto.failText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireItem(Map<String, Item> items, String id, String field) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
|
||||||
|
}
|
||||||
|
if (!items.containsKey(id)) {
|
||||||
|
throw new WorldLoadException(
|
||||||
|
"Combination references unknown item '" + id + "' (field '" + field + "')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationFactoryTest`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/loader/dto/CombinationDto.java \
|
||||||
|
src/main/java/thb/jeanluc/adventure/loader/CombinationFactory.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/loader/CombinationFactoryTest.java
|
||||||
|
git commit -m "feat(loader): CombinationDto + CombinationFactory with id validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `Combinations` engine
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/main/java/thb/jeanluc/adventure/game/Combinations.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
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 thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CombinationsTest {
|
||||||
|
|
||||||
|
private Item plain(String id, boolean light) {
|
||||||
|
return PlainItem.builder().id(id).name(id).description("d").light(light).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World with matches/torch/lit_torch/shovel and the given recipes; player in an empty room. */
|
||||||
|
private GameContext ctx(Combination... recipes) {
|
||||||
|
Map<String, Item> items = new LinkedHashMap<>();
|
||||||
|
items.put("matches", plain("matches", false));
|
||||||
|
items.put("torch", plain("torch", false));
|
||||||
|
items.put("lit_torch", plain("lit_torch", true));
|
||||||
|
items.put("shovel", plain("shovel", false));
|
||||||
|
Map<String, Combination> combos = new HashMap<>();
|
||||||
|
for (Combination c : recipes) {
|
||||||
|
combos.put(c.key(), c);
|
||||||
|
}
|
||||||
|
Room start = new Room("start", "Start", "d");
|
||||||
|
World w = new World(Map.of("start", start), items, Map.of(), "t", "w",
|
||||||
|
Map.of(), List.of(), combos);
|
||||||
|
return new GameContext(w, new Player(start, 0), new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void give(GameContext ctx, String id) {
|
||||||
|
ctx.getPlayer().addItem(ctx.getWorld().getItems().get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putInRoom(GameContext ctx, String id) {
|
||||||
|
ctx.getPlayer().getCurrentRoom().addItem(ctx.getWorld().getItems().get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String out(GameContext ctx) {
|
||||||
|
return ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Combination torchRecipe() {
|
||||||
|
return new Combination("matches", "torch", List.of(),
|
||||||
|
List.of("matches", "torch"), "lit_torch", List.of(),
|
||||||
|
"You light the torch.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinesFromInventory() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(ctx.getPlayer().hasItem("matches")).isFalse();
|
||||||
|
assertThat(ctx.getPlayer().hasItem("torch")).isFalse();
|
||||||
|
assertThat(out(ctx)).contains("light the torch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchIsOrderIndependent() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "torch", "matches");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumesOperandFromCurrentRoom() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches");
|
||||||
|
putInRoom(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().findItem("torch")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOperandReportsNoSuch() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches"); // torch absent
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(out(ctx)).contains("no 'torch'");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noRecipeForPairSaysNothingHappens() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "shovel");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "shovel", "torch");
|
||||||
|
assertThat(out(ctx)).contains("Nothing happens.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmetRequiresShowsFailTextAndDoesNotProduce() {
|
||||||
|
Combination gated = new Combination("matches", "torch",
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "dry")),
|
||||||
|
List.of("matches", "torch"), "lit_torch", List.of(),
|
||||||
|
"It catches.", "The torch is too damp to light.");
|
||||||
|
GameContext ctx = ctx(gated);
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(out(ctx)).contains("too damp");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse();
|
||||||
|
assertThat(ctx.getPlayer().hasItem("matches")).isTrue(); // not consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metRequiresAppliesEffectsAndProduces() {
|
||||||
|
Combination gated = new Combination("matches", "torch",
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "dry")),
|
||||||
|
List.of("matches", "torch"), "lit_torch",
|
||||||
|
List.of(new Effect(Effect.Type.SET_FLAG, "torch_lit")),
|
||||||
|
"It catches.", "too damp");
|
||||||
|
GameContext ctx = ctx(gated);
|
||||||
|
ctx.getState().set("dry");
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(ctx.getState().isSet("torch_lit")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationsTest`
|
||||||
|
Expected: FAIL — `Combinations` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `Combinations`**
|
||||||
|
|
||||||
|
`src/main/java/thb/jeanluc/adventure/game/Combinations.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies {@code use X on Y} item combinations. Stateless utility, mirroring
|
||||||
|
* {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the
|
||||||
|
* player's inventory or the current room.
|
||||||
|
*/
|
||||||
|
public final class Combinations {
|
||||||
|
|
||||||
|
private Combinations() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to combine the two named items. Prints a resolution error if an
|
||||||
|
* operand is absent, the recipe's {@code failText} (or a generic line) if a
|
||||||
|
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
|
||||||
|
* or applies consume/produce/effects + the response on success.
|
||||||
|
*/
|
||||||
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
|
if (!present(ctx, idX)) {
|
||||||
|
ctx.getIo().write(noSuch(idX));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present(ctx, idY)) {
|
||||||
|
ctx.getIo().write(noSuch(idY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
|
||||||
|
if (c == null) {
|
||||||
|
ctx.getIo().write("Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Conditions.all(c.requires(), ctx)) {
|
||||||
|
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String id : c.consume()) {
|
||||||
|
removeFromAnywhere(ctx, id);
|
||||||
|
}
|
||||||
|
if (c.produce() != null) {
|
||||||
|
Item produced = ctx.getWorld().getItems().get(c.produce());
|
||||||
|
if (produced != null) {
|
||||||
|
ctx.getPlayer().addItem(produced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effects.applyAll(c.effects(), ctx);
|
||||||
|
ctx.getIo().write(c.response());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean present(GameContext ctx, String id) {
|
||||||
|
return ctx.getPlayer().hasItem(id)
|
||||||
|
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeFromAnywhere(GameContext ctx, String id) {
|
||||||
|
if (ctx.getPlayer().removeItem(id).isEmpty()) {
|
||||||
|
ctx.getPlayer().getCurrentRoom().removeItem(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String noSuch(String id) {
|
||||||
|
return "There is no '" + id + "' here or in your inventory.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationsTest`
|
||||||
|
Expected: PASS (7 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/game/Combinations.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/game/CombinationsTest.java
|
||||||
|
git commit -m "feat(game): Combinations engine for use X on Y"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Route `UseCommand` to combinations
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
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 thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class UseCommandComboTest {
|
||||||
|
|
||||||
|
private GameContext ctx() {
|
||||||
|
Map<String, Item> items = new LinkedHashMap<>();
|
||||||
|
items.put("matches", PlainItem.builder().id("matches").name("Matches").description("d").light(false).build());
|
||||||
|
items.put("torch", PlainItem.builder().id("torch").name("Torch").description("d").light(false).build());
|
||||||
|
items.put("lit_torch", PlainItem.builder().id("lit_torch").name("Lit Torch").description("d").light(true).build());
|
||||||
|
SwitchableItem lamp = SwitchableItem.builder()
|
||||||
|
.id("lamp").name("Lamp").description("d").light(true)
|
||||||
|
.onText("The lamp glows.").offText("off").state(false).effects(List.of()).build();
|
||||||
|
items.put("lamp", lamp);
|
||||||
|
Combination recipe = new Combination("matches", "torch", List.of(),
|
||||||
|
List.of("matches", "torch"), "lit_torch", List.of(), "You light the torch.", null);
|
||||||
|
Room start = new Room("start", "Start", "d");
|
||||||
|
World w = new World(Map.of("start", start), items, Map.of(), "t", "w",
|
||||||
|
Map.of(), List.of(), Map.of(recipe.key(), recipe));
|
||||||
|
GameContext ctx = new GameContext(w, new Player(start, 0), new TestIO());
|
||||||
|
ctx.getPlayer().addItem(items.get("matches"));
|
||||||
|
ctx.getPlayer().addItem(items.get("torch"));
|
||||||
|
ctx.getPlayer().addItem(lamp);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyArgsAsksWhat() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
new UseCommand().execute(ctx, List.of());
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Use what?");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoArgsRoutesToCombination() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
new UseCommand().execute(ctx, List.of("matches", "torch"));
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("light the torch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneArgUsesItemDirectly() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
new UseCommand().execute(ctx, List.of("lamp"));
|
||||||
|
assertThat(((SwitchableItem) ctx.getWorld().getItems().get("lamp")).isOn()).isTrue();
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("lamp glows");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=UseCommandComboTest`
|
||||||
|
Expected: FAIL — `twoArgsRoutesToCombination` fails (current `UseCommand` treats only the first arg, no combination).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the combination branch to `UseCommand`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java`:
|
||||||
|
|
||||||
|
Add the import:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert the two-arg branch in `execute`, right after the empty-args check and before `String itemId = args.getFirst();`:
|
||||||
|
```java
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the `help()` text:
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public String help() {
|
||||||
|
return "use <item> - use an item; or use <item> on <item> to combine them";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=UseCommandComboTest`
|
||||||
|
Expected: PASS (3 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/command/impl/UseCommand.java \
|
||||||
|
src/test/java/thb/jeanluc/adventure/command/impl/UseCommandComboTest.java
|
||||||
|
git commit -m "feat(command): route use X on Y to the combination engine"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Loader wiring + demo content
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`
|
||||||
|
- Modify: `src/main/resources/world/items.yaml`
|
||||||
|
- Modify: `src/main/resources/world/rooms.yaml`
|
||||||
|
- Create: `src/main/resources/world/combinations.yaml`
|
||||||
|
- Test: `src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
`src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java`:
|
||||||
|
```java
|
||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CombinationLoadingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void realWorldLoadsTheTorchRecipe() {
|
||||||
|
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||||
|
assertThat(loaded.world().getCombinations())
|
||||||
|
.containsKey(Combination.key("matches", "torch"));
|
||||||
|
assertThat(loaded.world().getItems()).containsKeys("matches", "torch", "lit_torch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void torchRecipeWorksEndToEnd() {
|
||||||
|
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||||
|
GameContext ctx = new GameContext(
|
||||||
|
new GameSession(loaded.world(), loaded.player(), "test"), new TestIO());
|
||||||
|
// grab the demo items straight from the registry, regardless of room
|
||||||
|
ctx.getPlayer().addItem(ctx.getWorld().getItems().get("matches"));
|
||||||
|
ctx.getPlayer().addItem(ctx.getWorld().getItems().get("torch"));
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationLoadingTest`
|
||||||
|
Expected: FAIL — no `combinations.yaml`/`matches` items yet; `getCombinations()` is empty.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire `WorldLoader`**
|
||||||
|
|
||||||
|
In `src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java`:
|
||||||
|
|
||||||
|
Add imports:
|
||||||
|
```java
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
```
|
||||||
|
|
||||||
|
In `load()`, add the read alongside the other optional reads (after the `endingDtos` line):
|
||||||
|
```java
|
||||||
|
List<CombinationDto> combinationDtos =
|
||||||
|
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
|
||||||
|
```
|
||||||
|
|
||||||
|
After the `endings` list is built (and after `items` is populated), build the combinations map:
|
||||||
|
```java
|
||||||
|
Map<String, Combination> combinations = new HashMap<>();
|
||||||
|
for (CombinationDto dto : combinationDtos) {
|
||||||
|
Combination c = CombinationFactory.fromDto(dto, items);
|
||||||
|
String key = c.key();
|
||||||
|
if (combinations.containsKey(key)) {
|
||||||
|
throw new WorldLoadException("Duplicate combination for pair '" + key + "'");
|
||||||
|
}
|
||||||
|
combinations.put(key, c);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `World` construction to the 8-arg form:
|
||||||
|
```java
|
||||||
|
World world = new World(rooms, items, npcs,
|
||||||
|
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add demo items to `items.yaml`**
|
||||||
|
|
||||||
|
Append to `src/main/resources/world/items.yaml`:
|
||||||
|
```yaml
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: matches
|
||||||
|
name: Box of Matches
|
||||||
|
description: A small box of dry matches. They rattle when you shake it.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: torch
|
||||||
|
name: Unlit Torch
|
||||||
|
description: A pitch-soaked torch, waiting for a flame.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: lit_torch
|
||||||
|
name: Lit Torch
|
||||||
|
description: A burning torch, throwing back the dark.
|
||||||
|
light: true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Place demo items in rooms**
|
||||||
|
|
||||||
|
In `src/main/resources/world/rooms.yaml`:
|
||||||
|
- Change the `library` room's items line from `items: [shovel]` to:
|
||||||
|
```yaml
|
||||||
|
items: [shovel, matches]
|
||||||
|
```
|
||||||
|
- Change the `hallway` room's items line from `items: []` to:
|
||||||
|
```yaml
|
||||||
|
items: [torch]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create `combinations.yaml`**
|
||||||
|
|
||||||
|
`src/main/resources/world/combinations.yaml`:
|
||||||
|
```yaml
|
||||||
|
- a: matches
|
||||||
|
b: torch
|
||||||
|
consume: [matches, torch]
|
||||||
|
produce: lit_torch
|
||||||
|
response: |
|
||||||
|
You strike a match and touch it to the torch; it catches with a low hiss
|
||||||
|
and burns steady. You now hold a lit torch.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `mvn -q test -Dtest=CombinationLoadingTest`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Full suite (no regressions from new items)**
|
||||||
|
|
||||||
|
Run: `mvn -q clean test`
|
||||||
|
Expected: BUILD SUCCESS, all tests green. (WorldLoaderTest uses its own test fixture base, not these production resources, so it is unaffected.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/main/java/thb/jeanluc/adventure/loader/WorldLoader.java \
|
||||||
|
src/main/resources/world/items.yaml \
|
||||||
|
src/main/resources/world/rooms.yaml \
|
||||||
|
src/main/resources/world/combinations.yaml \
|
||||||
|
src/test/java/thb/jeanluc/adventure/loader/CombinationLoadingTest.java
|
||||||
|
git commit -m "feat(loader): load combinations.yaml; demo matches+torch -> lit_torch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/enhancement-ideas.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Mark the combination mechanic implemented**
|
||||||
|
|
||||||
|
In `docs/enhancement-ideas.md`, under section **5b** (Mechanik-Spine), add a `> ✅`
|
||||||
|
blockquote note after the bullet for mechanic **4. Item-Kombination**, mirroring
|
||||||
|
the existing `> ✅ umgesetzt (Branch ...)` style used for light/darkness etc.:
|
||||||
|
|
||||||
|
> ✅ umgesetzt (Branch `feature/use-x-on-y`): `use X on Y` als datengetriebene
|
||||||
|
> Rezepte (`combinations.yaml`: requires/consume/produce/effects/response/failText),
|
||||||
|
> Operanden aus Inventar oder Raum, reihenfolge-unabhängig. Kein Parser-Eingriff
|
||||||
|
> (`on`/`with`/`to` sind bereits Filler). Demo: `matches` + `torch` → `lit_torch`
|
||||||
|
> (dauerhafte Lichtquelle).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/enhancement-ideas.md
|
||||||
|
git commit -m "docs: mark use X on Y item combination implemented"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
- [ ] `mvn -q clean test` — all tests pass (162 prior + the new combination suites).
|
||||||
|
- [ ] Manual console smoke (optional): start a game, navigate to the hallway (`take torch`) and library (`take matches`), then `use matches on torch` → "You ... lit torch"; `inventory` shows `lit_torch`, `matches`/`torch` gone.
|
||||||
|
- [ ] `git status` clean.
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Spec: Hintergrundmusik (GUI)
|
||||||
|
|
||||||
|
Stand: 2026-06-01. Letzte der geplanten Mechaniken (nach `use X on Y`,
|
||||||
|
Pathfinding, Tutorial). Setzt Backlog #8 um: **Hintergrundmusik nur in der GUI**,
|
||||||
|
pro Raum via YAML, externer `music/`-Ordner, gestreamtes OGG. Baut auf der
|
||||||
|
`GameIO`-Abstraktion, dem Per-Zug-Push (`Game.publishHud`), dem datengetriebenen
|
||||||
|
Loader und der minimalen Settings auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Bisher gibt es keinen Ton. Ziel: **Hintergrundmusik in der GUI**, pro Raum über
|
||||||
|
ein optionales `music:`-Feld in `rooms.yaml`. Beim Raumwechsel sanfter Übergang;
|
||||||
|
Lautstärke/Aus über die Settings. Konsole bleibt stumm (No-Op).
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
||||||
|
- **Format/Lib**: **OGG Vorbis** via `vorbisspi` + `jorbis` (javax.sound-SPI),
|
||||||
|
gestreamt über eine `SourceDataLine`.
|
||||||
|
- **Wechsel**: gleicher Track → unverändert weiter (kein Neustart); anderer Track →
|
||||||
|
**kurzes Fade-out, dann Fade-in** (~400 ms, sequenziell, eine Line);
|
||||||
|
Raum **ohne** `music` → **Fade to silence**.
|
||||||
|
- **Settings**: eine zyklische **Music**-Stufe `OFF/LOW/MEDIUM/HIGH`, persistiert;
|
||||||
|
`OFF` deaktiviert Wiedergabe. Default **MEDIUM**.
|
||||||
|
- **Dateien**: externer `music/`-Ordner (Arbeitsverzeichnis), **gitignored**, nicht
|
||||||
|
im JAR; fehlende Datei → still + Log, kein Absturz.
|
||||||
|
- **Demo**: einige Räume mit Beispiel-`music:`-Feldern + Doku zum `music/`-Ordner.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `MusicLevel`-Enum (OFF/LOW/MEDIUM/HIGH + `gainDb()`).
|
||||||
|
- `MusicController` (frontend-agnostisch, **testbar**): Track-/Level-Logik,
|
||||||
|
delegiert an ein `MusicBackend`.
|
||||||
|
- `MusicBackend`-Interface (Roh-Operationen) + `OggMusicBackend` (Audio, **manuell**).
|
||||||
|
- `GameIO.setMusic(String)` + `setMusicLevel(MusicLevel)` (Default No-Op);
|
||||||
|
`SwingIO`-Override mit `MusicController(new OggMusicBackend())`.
|
||||||
|
- `Game.publishHud` ruft `setMusic(currentRoom.getMusic())`; nach `game.run()`
|
||||||
|
zurück ins Menü → `setMusic(null)`.
|
||||||
|
- `Room.music` + `RoomDto.music` + `RoomFactory`; optionales `music:` in `rooms.yaml`.
|
||||||
|
- `Settings.musicLevel` + `SettingsStore` (fehlt → MEDIUM); `SettingsMenu`-Zeile
|
||||||
|
„Music"; Anwendung via `io.setMusicLevel(...)` (Konsole No-Op).
|
||||||
|
- `pom.xml`: `vorbisspi` + `jorbis`.
|
||||||
|
- `music/` in `.gitignore` + kurze Doku.
|
||||||
|
- Tests: `MusicController` (Fake-Backend), `MusicLevel`, Settings-Roundtrip,
|
||||||
|
Room-`music`-Laden.
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Konsolen-Audio (bleibt stumm).
|
||||||
|
- True Cross-Fade (überlappende Lines) — nur sequenzielles Fade.
|
||||||
|
- Sound-Effekte / mehrere gleichzeitige Tracks.
|
||||||
|
- Mitliefern von Audiodateien im Repo.
|
||||||
|
- Automatisierte Tests der echten OGG-Dekodierung/-Wiedergabe (manuell, GUI-only).
|
||||||
|
|
||||||
|
## 3. `MusicLevel` (model oder io)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public enum MusicLevel {
|
||||||
|
OFF, LOW, MEDIUM, HIGH;
|
||||||
|
|
||||||
|
/** MASTER_GAIN in dB; für OFF irrelevant (Wiedergabe deaktiviert). */
|
||||||
|
public float gainDb() {
|
||||||
|
return switch (this) {
|
||||||
|
case OFF -> Float.NEGATIVE_INFINITY;
|
||||||
|
case LOW -> -20f;
|
||||||
|
case MEDIUM -> -10f;
|
||||||
|
case HIGH -> 0f;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public boolean isOn() { return this != OFF; }
|
||||||
|
|
||||||
|
/** Nächste Stufe im Zyklus (für den Settings-Toggle). */
|
||||||
|
public MusicLevel next() {
|
||||||
|
return values()[(ordinal() + 1) % values().length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Platzierung: `io`-Paket (UI-nah, wie `ConsoleIO.ColorMode`). Settings referenziert es.
|
||||||
|
|
||||||
|
## 4. `MusicBackend` + `MusicController` (io)
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Raw audio operations; the only part that touches sound hardware. */
|
||||||
|
public interface MusicBackend {
|
||||||
|
void fadeTo(String track, float gainDb); // fade current out, start track (loop), fade in
|
||||||
|
void fadeOut(); // fade current out + stop
|
||||||
|
void setGainDb(float gainDb); // immediate volume change
|
||||||
|
void shutdown(); // stop + release resources
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Frontend-agnostic music decision logic. Idempotent per track; honours level. */
|
||||||
|
public final class MusicController {
|
||||||
|
private final MusicBackend backend;
|
||||||
|
private MusicLevel level = MusicLevel.MEDIUM;
|
||||||
|
private String current; // currently selected track id, or null = silence
|
||||||
|
|
||||||
|
public MusicController(MusicBackend backend) { this.backend = backend; }
|
||||||
|
|
||||||
|
/** Called per turn with the current room's music field (may be null). */
|
||||||
|
public void room(String track) {
|
||||||
|
if (!level.isOn()) { return; } // OFF: stay silent, ignore
|
||||||
|
String t = (track == null || track.isBlank()) ? null : track;
|
||||||
|
if (java.util.Objects.equals(t, current)) { return; } // same → keep playing
|
||||||
|
current = t;
|
||||||
|
if (t == null) { backend.fadeOut(); } else { backend.fadeTo(t, level.gainDb()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings change. */
|
||||||
|
public void level(MusicLevel newLevel) {
|
||||||
|
this.level = newLevel;
|
||||||
|
if (!newLevel.isOn()) { backend.fadeOut(); current = null; }
|
||||||
|
else { backend.setGainDb(newLevel.gainDb()); }
|
||||||
|
// turning ON again starts on the next room() call (current was reset to null)
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() { backend.shutdown(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: Bei Wechsel OFF→ON beginnt die Musik beim nächsten `room(...)` (pro Zug
|
||||||
|
ohnehin aufgerufen), da `current` auf null gesetzt wurde. Das vermeidet, dass der
|
||||||
|
Controller den aktuellen Raum kennen muss.
|
||||||
|
|
||||||
|
## 5. `OggMusicBackend` (io, Audio, manuell)
|
||||||
|
|
||||||
|
- Hält den aktuellen Wiedergabe-Thread (Daemon) + `SourceDataLine`.
|
||||||
|
- `fadeTo(track, gainDb)`: laufenden Track per `MASTER_GAIN`-Rampe (~400 ms)
|
||||||
|
ausblenden, Thread stoppen (`volatile boolean stopping`), `music/<track>` öffnen
|
||||||
|
(`AudioSystem.getAudioInputStream` → vorbisspi liefert dekodiertes PCM), in einer
|
||||||
|
Schleife (Loop) in die Line schreiben, auf `gainDb` einblenden.
|
||||||
|
- `fadeOut()`: Rampe runter, Thread stoppen, Line schließen.
|
||||||
|
- `setGainDb(db)`: `MASTER_GAIN`-FloatControl sofort setzen.
|
||||||
|
- `shutdown()`: stoppen + Ressourcen freigeben.
|
||||||
|
- **Fehlende Datei / Decode-Fehler**: `log.warn`, still (kein Throw). `music/` fehlt
|
||||||
|
ganz → ebenso still.
|
||||||
|
- Threading: Wiedergabe auf Daemon-Thread; Fades blockierend auf dem aufrufenden
|
||||||
|
Thread (Worker), kurz (~400 ms) — analog zur bestehenden `travelStep`-Pause.
|
||||||
|
|
||||||
|
`music/<track>` = `Path.of("music", track)` relativ zum Arbeitsverzeichnis.
|
||||||
|
|
||||||
|
## 6. `GameIO`-Anbindung
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Per-turn current-room track; null = silence. Console no-op; GUI plays. */
|
||||||
|
default void setMusic(String track) { /* no-op */ }
|
||||||
|
|
||||||
|
/** Apply the music volume/level. Console no-op; GUI sets it. */
|
||||||
|
default void setMusicLevel(MusicLevel level) { /* no-op */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `SwingIO`: Feld `MusicController music = new MusicController(new OggMusicBackend());`
|
||||||
|
`setMusic(t) → music.room(t)`; `setMusicLevel(l) → music.level(l)`.
|
||||||
|
- `ConsoleIO`: erbt die No-Op-Defaults (kein Audio).
|
||||||
|
- `Game.publishHud()`: zusätzlich `ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());`
|
||||||
|
(idempotent → Wechsel nur bei echtem Raumwechsel).
|
||||||
|
- `App.play(...)`: nach `game.run()` → `io.setMusic(null);` (Musik beim Rückkehr ins
|
||||||
|
Menü ausblenden).
|
||||||
|
|
||||||
|
## 7. Room-Feld + Loader
|
||||||
|
|
||||||
|
- `Room`: optionales `private final String music;` (+ Getter). Konstruktor/Factory
|
||||||
|
erweitern; null = kein Track.
|
||||||
|
- `RoomDto`: optionales `music`-Feld (nullable).
|
||||||
|
- `RoomFactory.shellFromDto`: `music` durchreichen.
|
||||||
|
- `rooms.yaml`: optionales `music: <datei>.ogg` pro Raum.
|
||||||
|
- Demo: z.B. `kitchen: music: manor-theme.ogg`, `dungeon: music: dungeon-drone.ogg`
|
||||||
|
(Dateien liefert der Nutzer; fehlend → still).
|
||||||
|
|
||||||
|
## 8. Settings-Erweiterung
|
||||||
|
|
||||||
|
- `Settings` (record) bekommt `MusicLevel musicLevel`; `defaults()` → MEDIUM.
|
||||||
|
- `SettingsStore`: Jackson serialisiert `MusicLevel` als Namen; alter Spielstand
|
||||||
|
ohne Feld → `null` → auf MEDIUM normalisieren (im `load()` oder via Defaults).
|
||||||
|
- `SettingsMenu`: dritte Zeile „Music: <level>"; Auswahl zyklt `level.next()`;
|
||||||
|
nach jeder Änderung speichern + `applySettings`.
|
||||||
|
- **Refactor**: `SettingsMenu.apply/show` nehmen statt `ConsoleIO` das `GameIO`
|
||||||
|
entgegen. `apply(Settings, GameIO io)`:
|
||||||
|
`io.setMusicLevel(s.musicLevel());` **und** `if (io instanceof ConsoleIO c) {
|
||||||
|
c.setColorMode(...); c.setGlyphMode(...); }`. Aufrufer (App, `MenuTest`) anpassen.
|
||||||
|
- `App.run`: beim Start `SettingsMenu.apply(settings, io)` (setzt auch Music-Level).
|
||||||
|
|
||||||
|
## 9. Abhängigkeit + Dateien
|
||||||
|
|
||||||
|
- `pom.xml` (`<dependencies>`):
|
||||||
|
- `com.googlecode.soundlibs:vorbisspi:1.0.3.3`
|
||||||
|
- `com.googlecode.soundlibs:jorbis:0.0.17.4`
|
||||||
|
(vorbisspi zieht `tritonus-share` transitiv; bei Bedarf explizit ergänzen.)
|
||||||
|
- `.gitignore`: `music/`.
|
||||||
|
- Kurze Doku (z.B. in `docs/` oder README-Abschnitt): „Lege `.ogg`-Dateien in
|
||||||
|
`music/` ab; Räume referenzieren sie über `music:` in `rooms.yaml`. Ohne Dateien
|
||||||
|
bleibt die GUI stumm."
|
||||||
|
|
||||||
|
## 10. Tests
|
||||||
|
|
||||||
|
- **`MusicControllerTest`** (Fake-`MusicBackend`, das Aufrufe protokolliert):
|
||||||
|
- `room("a")` → `fadeTo("a", MEDIUM.gainDb())`; erneut `room("a")` → kein Aufruf.
|
||||||
|
- `room("b")` → `fadeTo("b", ...)`; `room(null)` → `fadeOut()`.
|
||||||
|
- `level(OFF)` → `fadeOut()` + danach ignoriert `room("a")` (still).
|
||||||
|
- `level(HIGH)` (von MEDIUM) → `setGainDb(HIGH)`; danach `room("a")` spielt.
|
||||||
|
- leerer/blank Track == null.
|
||||||
|
- **`MusicLevelTest`**: `gainDb()`-Mapping; `next()`-Zyklus; `isOn()`.
|
||||||
|
- **`SettingsStoreTest`** (erweitern): musicLevel round-trip; fehlendes Feld → MEDIUM.
|
||||||
|
- **`SettingsMenu`/`MenuTest`** (anpassen an `GameIO`-Signatur): Music-Zeile zyklt
|
||||||
|
und persistiert.
|
||||||
|
- **Room-`music`-Laden**: Loader-/Factory-Test, dass `music:` auf `Room.getMusic()`
|
||||||
|
landet (und Abwesenheit → null).
|
||||||
|
- `OggMusicBackend` + echte Wiedergabe: **manuell** (eine `.ogg` + Audio-Hardware),
|
||||||
|
keine Unit-Tests (Konvention: kein GUI-/Hardware-abhängiger Test).
|
||||||
|
|
||||||
|
## 11. Architektur-Werte / Risiken
|
||||||
|
|
||||||
|
- **Geteilter Loop bleibt**: nur zwei neue `GameIO`-No-Op-Defaults; Musik-Logik im
|
||||||
|
testbaren `MusicController`, Audio isoliert im `OggMusicBackend`. Konsole stumm.
|
||||||
|
- **Testbarkeit**: die wertvolle Entscheidungslogik (Idempotenz, null→Stille, OFF)
|
||||||
|
ist gegen ein Fake-Backend voll getestet; Hardware-Audio bleibt manuell.
|
||||||
|
- **Datengetrieben**: `music:` pro Raum in YAML; keine Hardcodierung.
|
||||||
|
- **Externe Dateien**: `music/` gitignored, nicht im JAR — Repo bleibt klein; fehlende
|
||||||
|
Dateien sind ein No-Op (graceful), kein Spielabbruch.
|
||||||
|
- **Risiko Lib**: vorbisspi/jorbis sind alt, funktionieren aber mit `javax.sound`.
|
||||||
|
Falls eine Datei nicht dekodierbar ist → graceful silence. Lizenz: LGPL/BSD-artig,
|
||||||
|
für eine Erweiterung erlaubt (Backlog: Bibliotheken erlaubt).
|
||||||
|
- **Threading**: Fades blockieren kurz den Worker-Thread (~400 ms, wie `travelStep`);
|
||||||
|
Wiedergabe auf Daemon-Thread. Beim Spielende `setMusic(null)` → Stille im Menü.
|
||||||
|
- **Settings-Refactor**: `SettingsMenu` von `ConsoleIO` auf `GameIO` umstellen ist die
|
||||||
|
einzige Änderung an bestehendem, bereits reviewtem Code — minimal und nötig, damit
|
||||||
|
der Music-Level beide Frontends erreicht (Konsole No-Op).
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Spec: Pathfinding `go to <room>`
|
||||||
|
|
||||||
|
Stand: 2026-06-01. Zweite der verbleibenden Mechaniken (nach `use X on Y`).
|
||||||
|
Setzt den in `enhancement-ideas.md` als Algorithmus-Showcase notierten Punkt um:
|
||||||
|
`go to <raum>` per BFS. Baut auf `GoCommand`, `MapLayout` (BFS-Gitter + Fog of
|
||||||
|
War), `Light`, `Conditions`/`ExitLock` und der `GameIO`-Abstraktion auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Bewegung erfolgt bisher Schritt für Schritt per `go <richtung>`. Ziel: **`go to
|
||||||
|
<raum>`** läuft automatisch per **BFS** über bereits **besuchte** Räume zum Ziel,
|
||||||
|
unter Beachtung derselben Gates wie ein manueller Zug (verschlossene Exits, Licht).
|
||||||
|
In der **GUI** soll die Minikarte die Bewegung **Raum für Raum animiert** zeigen;
|
||||||
|
in der Konsole läuft die Reise still mit einer knappen Zusammenfassung.
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
||||||
|
- **Erreichbarkeit**: nur **besuchte** Räume sind Wegpunkte/Ziele (Fog of War bleibt
|
||||||
|
erhalten; kein Teleport in Unentdecktes).
|
||||||
|
- **Darstellung**: Konsole still + Zusammenfassung + Ziel-`look`; GUI animiert die
|
||||||
|
Minikarte (Map-Push pro Schritt).
|
||||||
|
- **Zusammenfassung**: nennt **Start**, **Ziel** und **höchstens einen** mittleren
|
||||||
|
Wegpunkt (Mittelknoten). Bei 9 Knoten: 1., 5., 9.
|
||||||
|
- **Schrittverzögerung GUI**: ~**300 ms** pro Raum.
|
||||||
|
- **Trie-Autocomplete**: **zurückgestellt** (eigenes späteres Teilprojekt).
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `GoCommand`-Erweiterung: Argument ist Richtung → bestehender Einzelzug; sonst →
|
||||||
|
Raum-Ziel-Pathfinding. Keine neue Verb-Registrierung (Parser entfernt `to`).
|
||||||
|
- `Pathfinder` (`game`): BFS über besuchte Räume mit Passierbarkeits-Prädikat
|
||||||
|
(Exit-Lock + Licht), liefert den Pfad oder leer.
|
||||||
|
- Auto-Walk: Schritt für Schritt `setCurrentRoom` + `GameIO.travelStep(MapView)`;
|
||||||
|
danach Zusammenfassung + `LookCommand`.
|
||||||
|
- Neue `GameIO.travelStep(MapView)`-Default-Methode (No-Op Konsole) + `SwingIO`-
|
||||||
|
Override (Map-Push + ~300 ms Sleep).
|
||||||
|
- Ziel-Auflösung nur unter besuchten Räumen (id, dann name; case-insensitive).
|
||||||
|
- Zusammenfassung mit Start/Mitte/Ziel-Logik.
|
||||||
|
- Tests: `Pathfinder` (BFS, Locks, Licht, besucht-nur, unerreichbar), `GoCommand`
|
||||||
|
(Raum-Ziel, Meldungen, Richtung weiterhin).
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Trie-/Tab-Autocomplete (eigenes Teilprojekt).
|
||||||
|
- Routing durch **unbesuchte** Räume.
|
||||||
|
- Pfad-Neuplanung mitten im Lauf (Zustand ändert sich während eines Zuges nicht).
|
||||||
|
- Gewichtete Kanten / Kosten (alle Kanten gleich; BFS = kürzeste Raumzahl).
|
||||||
|
- Mehr-Wort-Item-/Richtungs-Sonderfälle über die beschriebene Auflösung hinaus.
|
||||||
|
|
||||||
|
## 3. Routing in `GoCommand`
|
||||||
|
|
||||||
|
Der Parser entfernt `to`/`with`/… als Filler, daher liefern `go to library` und
|
||||||
|
`go library` beide `args=[library]`. `execute` verzweigt:
|
||||||
|
|
||||||
|
```java
|
||||||
|
if (args.isEmpty()) { io.write("Go where? Try 'go north' or 'go to <room>'."); return; }
|
||||||
|
String first = args.getFirst();
|
||||||
|
try {
|
||||||
|
Direction dir = Direction.fromString(first);
|
||||||
|
moveDirection(ctx, dir); // bestehender Einzelzug (unverändert ausgelagert)
|
||||||
|
return;
|
||||||
|
} catch (IllegalArgumentException notADirection) {
|
||||||
|
goToRoom(ctx, args); // neuer Raum-Ziel-Pfad
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`moveDirection` = der bisherige Rumpf (Exit prüfen, Lock, Licht, setCurrentRoom,
|
||||||
|
look). `move`/`walk`-Aliase erben beides.
|
||||||
|
|
||||||
|
## 4. Ziel-Auflösung (`goToRoom`)
|
||||||
|
|
||||||
|
- Zielname = `String.join(" ", args).toLowerCase()` (`go to old kitchen` → "old kitchen").
|
||||||
|
- Suche **nur unter besuchten Räumen** (`player.getVisitedRoomIds()` → `world.getRooms().get(id)`):
|
||||||
|
Treffer, wenn `id.equals(name)` **oder** `room.getName().toLowerCase().equals(name)`.
|
||||||
|
- Kein Treffer → `"You don't know any direction or place called '<name>'."` (verrät keine
|
||||||
|
unentdeckten Räume).
|
||||||
|
- Ziel == aktueller Raum → `"You're already in the <Name>."`
|
||||||
|
- sonst → `Pathfinder.findPath`; leer → `"You can't find a way there right now."`;
|
||||||
|
sonst Auto-Walk (§6).
|
||||||
|
|
||||||
|
## 5. `Pathfinder` (game/Pathfinder.java)
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Shortest path (BFS) over visited rooms to target, honouring locks + light.
|
||||||
|
* Returns the steps AFTER the current room, ending at target; empty if none. */
|
||||||
|
public static List<Room> findPath(GameContext ctx, Room target) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
- BFS ab `player.getCurrentRoom()`; Queue + `Map<Room,Room> cameFrom`.
|
||||||
|
- Kante `from --dir--> to` passierbar gdw.:
|
||||||
|
1. `to` ist in `player.getVisitedRoomIds()`,
|
||||||
|
2. Lock: `from.getExitLocks().get(dir)` ist null **oder** `Conditions.all(lock.requires(), ctx)`,
|
||||||
|
3. Licht: `Light.isLit(ctx, to)`.
|
||||||
|
- Bei Erreichen von `target` Pfad via `cameFrom` rekonstruieren (ohne Startraum,
|
||||||
|
inkl. `target`). Unerreichbar → `List.of()`.
|
||||||
|
- Rein über die Domäne + `ctx`; keine IO. Testbar wie `MapLayoutTest`/`QuestEngineTest`.
|
||||||
|
|
||||||
|
Hinweis: Da nur besuchte Räume Kanten liefern und Start immer „besucht" ist, ist
|
||||||
|
die Suche auf die bekannte Karte beschränkt. `target == current` behandelt
|
||||||
|
`goToRoom` vorab (Pathfinder müsste sonst leeren Pfad liefern).
|
||||||
|
|
||||||
|
## 6. Auto-Walk + animierte Minikarte
|
||||||
|
|
||||||
|
```java
|
||||||
|
List<Room> path = Pathfinder.findPath(ctx, target);
|
||||||
|
if (path.isEmpty()) { io.write("You can't find a way there right now."); return; }
|
||||||
|
for (Room step : path) {
|
||||||
|
ctx.getPlayer().setCurrentRoom(step);
|
||||||
|
ctx.getIo().travelStep(MapLayout.compute(
|
||||||
|
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
|
||||||
|
}
|
||||||
|
ctx.getIo().write(summary(routeIncludingStart)); // §7
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
```
|
||||||
|
|
||||||
|
- `setCurrentRoom` markiert Räume als besucht (bereits besucht → idempotent).
|
||||||
|
- `go to` ist **ein** Zug (ein Command) — der Game-Loop erhöht `turn` einmal.
|
||||||
|
|
||||||
|
### `GameIO.travelStep(MapView)`
|
||||||
|
|
||||||
|
```java
|
||||||
|
/** Per-step travel frame during auto-walk. Console: no-op (silent). */
|
||||||
|
default void travelStep(MapView view) {
|
||||||
|
// no animation in text mode
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SwingIO`-Override:
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
public void travelStep(MapView view) {
|
||||||
|
setMap(view); // Map-Panel neu zeichnen (invokeLater)
|
||||||
|
try { Thread.sleep(TRAVEL_STEP_MS); } // ~300 ms, läuft auf dem Worker-Thread
|
||||||
|
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`TRAVEL_STEP_MS = 300`. Konsole bleibt still und ohne Pause (Default No-Op).
|
||||||
|
Der reguläre `publishHud` am Zugende setzt den finalen Map-/HUD-Stand.
|
||||||
|
|
||||||
|
## 7. Zusammenfassungs-Zeile (§ Wegpunkte)
|
||||||
|
|
||||||
|
Über die volle Route `route = [current] + path` (n = `route.size()`, Index 0..n-1):
|
||||||
|
- **Start** = `route[0]`, **Ziel** = `route[n-1]` immer genannt.
|
||||||
|
- **Mitte** = `route[n/2]` nur wenn `n >= 3` und der Index weder 0 noch n-1 ist
|
||||||
|
(also höchstens **ein** Zwischenpunkt). Bei n=9 → Index 4 (der 5. Knoten).
|
||||||
|
- Rendering:
|
||||||
|
- n == 2: `"You make your way from the <Start> to the <Ziel>."`
|
||||||
|
- n >= 3: `"You make your way from the <Start>, through the <Mitte>, to the <Ziel>."`
|
||||||
|
|
||||||
|
(Artikel „the" bewusst einfach gehalten; reine Flavor-Zeile, leicht anpassbar.)
|
||||||
|
|
||||||
|
## 8. Fehler-/Randfälle (Meldungen)
|
||||||
|
|
||||||
|
| Fall | Meldung |
|
||||||
|
|---|---|
|
||||||
|
| `go` ohne Argument | "Go where? Try 'go north' or 'go to <room>'." |
|
||||||
|
| Richtung ohne Exit | (bestehend) "You can't go <dir> from here." |
|
||||||
|
| Richtung gesperrt / dunkel | (bestehend) Lock-Text / Dunkelheits-Hinweis |
|
||||||
|
| Zielname unbekannt (unter besuchten) | "You don't know any direction or place called '<name>'." |
|
||||||
|
| Ziel == aktueller Raum | "You're already in the <Name>." |
|
||||||
|
| Ziel besucht, aber kein passierbarer Pfad | "You can't find a way there right now." |
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
- **`PathfinderTest`** (Welt inline, wie `MapLayoutTest`):
|
||||||
|
- kürzester Pfad über besuchte Räume (Länge + Knoten korrekt).
|
||||||
|
- unbesuchter Zwischenraum wird **nicht** als Wegpunkt genutzt (kein Pfad bzw.
|
||||||
|
Umweg nur über Besuchtes).
|
||||||
|
- kein Pfad durch einen Exit mit nicht erfüllter `ExitLock`-Bedingung; Pfad
|
||||||
|
erscheint, wenn die Flag gesetzt ist.
|
||||||
|
- kein Pfad in einen dunklen Raum ohne Licht; mit getragenem Licht erreichbar.
|
||||||
|
- unerreichbares Ziel → leere Liste.
|
||||||
|
- **`GoCommand`-Tests** (Raum-Ziel):
|
||||||
|
- `go to <name>` läuft zum Ziel (currentRoom aktualisiert, Ziel-`look` erfolgt).
|
||||||
|
- Auflösung per id **und** per name (case-insensitive).
|
||||||
|
- unbekannter Name / Ziel==aktuell / kein Pfad → jeweilige Meldung.
|
||||||
|
- `go north` (Richtung) funktioniert weiterhin (Regressionsschutz).
|
||||||
|
- **`travelStep`**: Default ist No-Op (Tests nutzen `TestIO`; keine Sleeps, keine
|
||||||
|
GUI). GUI-Animation wird manuell verifiziert (Konvention: keine GUI-Unit-Tests).
|
||||||
|
|
||||||
|
## 10. Architektur-Werte / Risiken
|
||||||
|
|
||||||
|
- **Gemeinsamer Loop bleibt**: nur eine neue `GameIO`-Default-Methode; Pacing/
|
||||||
|
Animation lebt im Renderer (`SwingIO`), Konsole No-Op. Keine Divergenz der Logik.
|
||||||
|
- **Konsistenz mit manuellem Zug**: Pathfinder nutzt dieselben Gates (Lock, Licht)
|
||||||
|
wie `GoCommand` — kein Auto-Walk durch etwas, das man manuell nicht passieren darf.
|
||||||
|
- **Fog of War**: nur besuchte Räume → kein Informationsleck über Unentdecktes.
|
||||||
|
- **Algorithmus-Showcase**: klassisches BFS (Queue + `cameFrom`) mit
|
||||||
|
Standard-Collections (entspricht der Projekt-Entscheidung „keine eigenen
|
||||||
|
`uebung`-Strukturen"). `MapLayout` macht bereits BFS — `Pathfinder` ist die
|
||||||
|
fokussierte Wegfindungs-Variante (eigene Verantwortlichkeit, eigene Tests).
|
||||||
|
- **GUI-Threading**: `travelStep` schläft auf dem Worker-Thread (wie `readLine`
|
||||||
|
blockiert), `setMap` zeichnet via EDT — kein Deadlock; EDT bleibt responsiv.
|
||||||
|
- Risiko: lange Pfade × 300 ms könnten sich zäh anfühlen; bewusst akzeptiert (kurze
|
||||||
|
Karte). Später ggf. Settings-gesteuert.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# Spec: Interaktives Start-Tutorial
|
||||||
|
|
||||||
|
Stand: 2026-06-01. Dritte der verbleibenden Mechaniken (nach `use X on Y` und
|
||||||
|
Pathfinding). Setzt das vom Nutzer gewünschte **interaktive Tutorial beim
|
||||||
|
Spielstart, abbrechbar per `skip`** um. Baut auf dem geteilten Game-Loop,
|
||||||
|
`CommandRegistry`/`CommandParser`, `GameIO` und dem datengetriebenen Loader auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
Neue Spieler kennen die Befehle nicht. Ziel: ein **interaktiver Walkthrough** zu
|
||||||
|
Beginn eines **Neuen Spiels**, der den Spieler im **echten Startraum** Schritt für
|
||||||
|
Schritt die Kernbefehle ausführen lässt (er wartet, bis der jeweilige Befehl kam),
|
||||||
|
mit Bestätigung/Hinweisen, und am Ende die übrigen Befehle als Tipps nennt.
|
||||||
|
Jederzeit per `skip` überspringbar. Geladene Spielstände zeigen kein Tutorial.
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
||||||
|
- **Stil**: interaktiv (wait-for-it) im echten Spiel, nicht in einer Sandbox.
|
||||||
|
- **Kern-Bogen** (interaktiv, in Reihenfolge): `look` → `examine` → `take` →
|
||||||
|
`inventory` → `use` → `go` (Richtung) → `go to` (Pathfinding). Danach
|
||||||
|
**Abschluss-Tipps** für den Rest (read, drop, talk/give, use X on Y, map,
|
||||||
|
quests, save, menu).
|
||||||
|
- **Abschluss-Erkennung verb-basiert** (alias-fähig): jede gültige Nutzung des
|
||||||
|
erwarteten Verbs zählt; optional `minArgs`.
|
||||||
|
- **Datengetrieben**: `tutorial.yaml`; Logik (Verb-/Richtungs-Matching) im Code.
|
||||||
|
- **Häufigkeit**: bei jedem Neuen Spiel; `skip` überspringt; keine Persistenz.
|
||||||
|
- **Falscher (aber gültiger) Befehl**: wird **ausgeführt** (echtes Spiel) + sanfter
|
||||||
|
Hinweis, Schritt rückt **nicht** vor.
|
||||||
|
- **Platzierung**: **nach** Banner + Willkommen + erstem Raum-`look`.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `TutorialGuide` (game), in den Game-Loop eingehängt (optional; null = kein Tutorial).
|
||||||
|
- `Tutorial`/`TutorialStep` (model) + `TutorialDto`/`TutorialStepDto` (loader/dto)
|
||||||
|
+ `TutorialLoader` (optionales `/world/tutorial.yaml`, eigener Loader — nicht in `World`).
|
||||||
|
- `Game`: optionaler Guide (`setTutorialGuide`) + `begin`/`onCommand`/`skip`-Anbindung.
|
||||||
|
- `App.play(..., withTutorial)`: Guide nur beim Neuen Spiel bauen.
|
||||||
|
- `tutorial.yaml`: Intro, 7 Kern-Schritte, Abschluss-Tipps.
|
||||||
|
- Tests: Guide-Logik, Loader, Game-Integration.
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Persistenz „Tutorial gesehen" / Settings-Toggle (eigene spätere Option).
|
||||||
|
- Sandbox-Welt / Zurücksetzen von Tutorial-Aktionen (es ist das echte Spiel).
|
||||||
|
- Erzwungenes `use X on Y` / `talk` etc. (nur in Abschluss-Tipps genannt).
|
||||||
|
- GUI-Sonderdarstellung (Tutorial nutzt die normale styled Ausgabe; `skip` per Eingabe).
|
||||||
|
|
||||||
|
## 3. Datenmodell (model)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record TutorialStep(
|
||||||
|
String instruction, // an den Spieler: was tun
|
||||||
|
String expect, // Verb-Name ("look"/"examine"/...) ODER "go_direction" / "go_to_room"
|
||||||
|
int minArgs, // Mindest-Argumentzahl (z.B. 1 für take/examine/use); 0 default
|
||||||
|
String confirm, // Bestätigung bei Erfolg
|
||||||
|
String hint // sanfter Hinweis bei gültigem-aber-falschem Befehl
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
|
||||||
|
public Tutorial {
|
||||||
|
steps = steps == null ? List.of() : List.copyOf(steps);
|
||||||
|
}
|
||||||
|
public boolean isEmpty() { return steps.isEmpty(); }
|
||||||
|
public static Tutorial none() { return new Tutorial(null, List.of(), null); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tutorial.yaml` (ein Mapping, optional):
|
||||||
|
```yaml
|
||||||
|
intro: |
|
||||||
|
Welcome. Let me show you the ropes — type the commands as I describe them.
|
||||||
|
(Type 'skip' at any time to jump straight into the game.)
|
||||||
|
steps:
|
||||||
|
- instruction: "Look around the room: type 'look'."
|
||||||
|
expect: look
|
||||||
|
confirm: "Good — 'look' (or 'l') always re-describes where you are."
|
||||||
|
hint: "When you're ready, just type: look"
|
||||||
|
- instruction: "Examine something you see: 'examine <thing>' (or 'x <thing>')."
|
||||||
|
expect: examine
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "That's how you inspect items, exits and people up close."
|
||||||
|
hint: "Try examining something, e.g.: examine lamp"
|
||||||
|
- instruction: "Pick something up: 'take <item>'."
|
||||||
|
expect: take
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Taken. Items you carry go into your inventory."
|
||||||
|
hint: "Pick something up, e.g.: take lamp"
|
||||||
|
- instruction: "Check what you're carrying: 'inventory' (or 'i')."
|
||||||
|
expect: inventory
|
||||||
|
confirm: "That's your inventory."
|
||||||
|
hint: "Type: inventory"
|
||||||
|
- instruction: "Use an item: 'use <item>' (e.g. light the lamp)."
|
||||||
|
expect: use
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Some items toggle, read or react when used."
|
||||||
|
hint: "Use something, e.g.: use lamp"
|
||||||
|
- instruction: "Move to another room: 'go <direction>' (try 'go north')."
|
||||||
|
expect: go_direction
|
||||||
|
confirm: "You can move north/south/east/west between connected rooms."
|
||||||
|
hint: "Head somewhere, e.g.: go north"
|
||||||
|
- instruction: "Travel back to a room you've seen: 'go to <room>'."
|
||||||
|
expect: go_to_room
|
||||||
|
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
|
||||||
|
hint: "Try: go to <a room you've already visited>"
|
||||||
|
closingTips: |
|
||||||
|
That's the core. A few more you'll want:
|
||||||
|
read · drop · talk to <npc> · give <item> to <npc> ·
|
||||||
|
use <item> on <item> (combine things) · map · quests · save · menu.
|
||||||
|
Type 'help' anytime. Good luck.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. `TutorialLoader` (loader)
|
||||||
|
|
||||||
|
- `loader/dto/TutorialDto` (`intro`, `List<TutorialStepDto> steps`, `closingTips`),
|
||||||
|
`loader/dto/TutorialStepDto` (`instruction`, `expect`, `Integer minArgs`, `confirm`, `hint`).
|
||||||
|
- `TutorialLoader.load(basePath)` → `Tutorial`: liest `basePath + "/tutorial.yaml"`
|
||||||
|
optional (fehlt → `Tutorial.none()`), mappt DTO→Modell (`minArgs` null → 0).
|
||||||
|
Nutzt einen eigenen Jackson-`YAMLFactory`-Mapper wie `WorldLoader`. Default-Base
|
||||||
|
`/world` (überschreibbar für Tests). Parse-Fehler → `WorldLoadException`.
|
||||||
|
- Bewusst **getrennt von `World`**: Tutorial ist Onboarding, kein Welt-Inhalt.
|
||||||
|
|
||||||
|
## 5. `TutorialGuide` (game)
|
||||||
|
|
||||||
|
Zustandsbehaftet (ein laufendes Tutorial), hält die Schritte + den Index + die
|
||||||
|
`CommandRegistry` (für alias-fähiges Verb-Matching).
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class TutorialGuide {
|
||||||
|
private final Tutorial tutorial;
|
||||||
|
private final CommandRegistry registry;
|
||||||
|
private int index = 0;
|
||||||
|
private boolean active;
|
||||||
|
private Room roomAtStepStart; // zum Erkennen echter Bewegung
|
||||||
|
|
||||||
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
|
this.tutorial = tutorial; this.registry = registry;
|
||||||
|
this.active = !tutorial.isEmpty();
|
||||||
|
}
|
||||||
|
public boolean isActive() { return active; }
|
||||||
|
|
||||||
|
/** Druckt Intro + erste Anweisung (am Loop-Start, nach dem ersten look). */
|
||||||
|
public void begin(GameContext ctx) { if (active) { print intro; enterStep(ctx); } }
|
||||||
|
|
||||||
|
/** Nach jedem ausgeführten Befehl: prüft den aktuellen Schritt. */
|
||||||
|
public void onCommand(GameContext ctx, ParsedCommand parsed) {
|
||||||
|
if (!active) return;
|
||||||
|
TutorialStep step = tutorial.steps().get(index);
|
||||||
|
if (satisfies(step, ctx, parsed)) {
|
||||||
|
ctx.getIo().write(step.confirm());
|
||||||
|
index++;
|
||||||
|
if (index >= tutorial.steps().size()) { ctx.getIo().print(closingTips); active = false; }
|
||||||
|
else enterStep(ctx);
|
||||||
|
} else {
|
||||||
|
ctx.getIo().write(step.hint()); // gültiger-aber-falscher Befehl → Nudge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'skip' während des Tutorials: Abbruch ohne Zug. */
|
||||||
|
public void skip(GameContext ctx) { if (active) { ctx.getIo().write("Tutorial skipped."); active = false; } }
|
||||||
|
|
||||||
|
/** Merkt den aktuellen Raum (für Bewegungs-Erkennung) und druckt die Anweisung. */
|
||||||
|
private void enterStep(GameContext ctx) {
|
||||||
|
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
||||||
|
ctx.getIo().write(tutorial.steps().get(index).instruction());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`satisfies(step, ctx, parsed)`**:
|
||||||
|
- `expect == "go_direction"` → `sameCommand(parsed.verb(), "go")` **und** `args` nicht leer
|
||||||
|
**und** erstes Arg ist eine Richtung (`Direction.fromString` wirft nicht) **und**
|
||||||
|
der Spieler ist **tatsächlich umgezogen** (`ctx.getPlayer().getCurrentRoom() != roomAtStepStart`).
|
||||||
|
- `expect == "go_to_room"` → `sameCommand(parsed.verb(), "go")` **und** `args` nicht leer
|
||||||
|
**und** erstes Arg ist **keine** Richtung **und** der Spieler ist tatsächlich umgezogen.
|
||||||
|
- sonst (Verb-Name) → `sameCommand(parsed.verb(), step.expect())` **und**
|
||||||
|
`args.size() >= step.minArgs()`.
|
||||||
|
- `sameCommand(a, b)` = `registry.find(a)` und `registry.find(b)` beide präsent und
|
||||||
|
**dieselbe** Command-Instanz (alias-fähig: `l`==`look`, `x`==`examine`, `get`==`take`,
|
||||||
|
`i`==`inventory`).
|
||||||
|
|
||||||
|
Die Bewegungs-Bedingung verhindert, dass ein **blockierter** Zug (z.B. `go south`
|
||||||
|
in den dunklen Dungeon ohne Licht → kein Raumwechsel) den Schritt vorrückt — wichtig,
|
||||||
|
damit für `go_to_room` danach wirklich zwei Räume besucht sind.
|
||||||
|
|
||||||
|
Hinweis: `onCommand` wird nur nach einem **erfolgreich aufgelösten** Befehl gerufen;
|
||||||
|
unbekannte Eingaben behandelt der Loop bereits („I don't understand …").
|
||||||
|
|
||||||
|
## 6. Game-Loop-Anbindung (game/Game.java)
|
||||||
|
|
||||||
|
- Neues optionales Feld `private TutorialGuide tutorialGuide;` + `setTutorialGuide(...)`
|
||||||
|
(Zwei-Phasen-Wiring wie `QuitCommand`/`MenuCommand`).
|
||||||
|
- In `run()` **vor** der `while`-Schleife (nach dem initialen `publishHud`/`maybeEnd`):
|
||||||
|
`if (tutorialGuide != null) tutorialGuide.begin(ctx);`
|
||||||
|
- In der Schleife, **vor** dem Dispatch: wenn `tutorialGuide != null &&
|
||||||
|
tutorialGuide.isActive() && parsed.verb().equals("skip")` → `tutorialGuide.skip(ctx);
|
||||||
|
publishHud(); continue;` (kein `turn++`).
|
||||||
|
- **Nach** `cmd.get().execute(...); turn++;`:
|
||||||
|
`if (tutorialGuide != null) tutorialGuide.onCommand(ctx, parsed);`
|
||||||
|
|
||||||
|
Der Guide ist in normalem/geladenem Spiel `null` → kein Overhead, kein Verhalten.
|
||||||
|
|
||||||
|
## 7. App-Anbindung (App.java)
|
||||||
|
|
||||||
|
- `App.run` lädt das Tutorial einmal beim Start: `Tutorial tutorial =
|
||||||
|
new TutorialLoader().load();` (wie Settings/SaveService).
|
||||||
|
- `play(io, saves, session, boolean withTutorial)`: nach dem Registry-Aufbau, wenn
|
||||||
|
`withTutorial && !tutorial.isEmpty()` → `game.setTutorialGuide(new TutorialGuide(
|
||||||
|
tutorial, registry))`.
|
||||||
|
- `case NEW_GAME -> play(io, saves, newSession(io), true);`
|
||||||
|
`LOAD -> play(io, saves, saves.load(slot.slug()), false);`
|
||||||
|
- Reihenfolge in `play`: Banner → Willkommen → erstes `look` → `game.run()` (darin
|
||||||
|
`guide.begin`). Damit erscheint das Tutorial **nach** dem ersten Raumbild.
|
||||||
|
|
||||||
|
## 8. Flow & Meldungen
|
||||||
|
|
||||||
|
- Neues Spiel → Name → Banner/Willkommen → Raum-`look` → Intro + 1. Anweisung.
|
||||||
|
- Spieler tippt Befehle (echte Ausführung); passender Befehl → `confirm` + nächste
|
||||||
|
Anweisung; falscher (gültiger) → `hint`. Nach dem letzten Schritt → `closingTips`,
|
||||||
|
Guide inaktiv, normales Spiel läuft weiter (Spieler steht, wo das Tutorial endete).
|
||||||
|
- `skip` jederzeit → „Tutorial skipped." → normales Spiel.
|
||||||
|
- Während des Tutorials laufen HUD/Karte/Quests/Autosave normal (es ist der echte Loop).
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
- **`TutorialGuideTest`** (Fake-`CommandRegistry` mit echten Command-Instanzen oder
|
||||||
|
reale Registry; `TestIO`):
|
||||||
|
- Alias-Match: `l` erfüllt `look`-Schritt; `x` erfüllt `examine`.
|
||||||
|
- `minArgs`: `take` ohne Arg erfüllt **nicht** (minArgs 1), `take lamp` schon.
|
||||||
|
- `go_direction` (Arg = Richtung) vs `go_to_room` (Arg = keine Richtung).
|
||||||
|
- Falscher gültiger Befehl → `hint`, kein Vorrücken.
|
||||||
|
- Vorrücken + `confirm`; nach letztem Schritt → `closingTips` + `isActive()==false`.
|
||||||
|
- `skip` → inaktiv, „Tutorial skipped.".
|
||||||
|
- **`TutorialLoaderTest`**: lädt Fixture-`tutorial.yaml` → Intro/Steps/ClosingTips;
|
||||||
|
fehlende Datei → `Tutorial.none()` (`isEmpty()`).
|
||||||
|
- **`GameTutorialTest`** (Integration): kleine Welt + reale Registry + `TestIO` mit
|
||||||
|
skript-Eingaben durch `game.run()`; prüft, dass `begin` druckt, Schritte bei
|
||||||
|
passenden Befehlen vorrücken, und `skip` beendet. (App-/GUI-Wiring manuell.)
|
||||||
|
- Konvention: keine GUI-/YAML-abhängigen Domain-Tests außer dem Loader-Fixture.
|
||||||
|
|
||||||
|
## 10. Architektur-Werte / Risiken
|
||||||
|
|
||||||
|
- **Geteilter Loop bleibt zentral**: der Guide hängt sich minimal in `Game` ein;
|
||||||
|
Konsole und GUI verhalten sich identisch (normale styled Ausgabe, `skip` per Eingabe).
|
||||||
|
- **Datengetrieben**: Schritte/Texte in `tutorial.yaml`; nur das Matching ist Code.
|
||||||
|
Verb-Matching nutzt die Registry (alias-fähig, keine hartkodierten Alias-Listen).
|
||||||
|
- **Geringe Kopplung an Content**: Schritte sind verb-/richtungs-basiert (kein fixer
|
||||||
|
Item-Name nötig); ändert sich der Start-Content, bleibt das Tutorial gültig.
|
||||||
|
`go_to_room` funktioniert, weil nach dem `go`-Schritt zwei Räume besucht sind.
|
||||||
|
- **Echtes Spiel, kein Sandbox**: Tutorial-Aktionen sind reale Züge (Turn/Quests/
|
||||||
|
Autosave laufen). Risiko: der Spieler „verbraucht" den ersten Raum — bewusst
|
||||||
|
akzeptiert (es ist die echte Welt; nichts geht verloren).
|
||||||
|
- **`skip`-Sonderfall**: nur während aktivem Tutorial abgefangen; außerhalb bleibt
|
||||||
|
`skip` ein unbekanntes Verb (harmlos).
|
||||||
|
- Risiko: `expect`-Tokens (`go_direction`/`go_to_room` + Verbnamen) sind ein kleines
|
||||||
|
bespoke Schema; dokumentiert in `tutorial.yaml`-Kommentar und im Loader.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Spec: Item-Kombination `use X on Y`
|
||||||
|
|
||||||
|
Stand: 2026-06-01. Erste der verbleibenden Mechaniken nach Hauptmenü +
|
||||||
|
Speichern/Laden. Setzt das in `enhancement-ideas.md` #5b (Mechanik-Spine §4)
|
||||||
|
festgelegte Item-Kombinations-Feature um. Baut auf der bestehenden
|
||||||
|
Condition/Effect-Engine, dem datengetriebenen Loader (wie quests/endings) und
|
||||||
|
dem vorhandenen `UseCommand` auf.
|
||||||
|
|
||||||
|
## 1. Kontext & Ziel
|
||||||
|
|
||||||
|
v1.0 hat bewusst argloses `use X` ohne Ziel gewählt. Ziel jetzt: **`use X on Y`**
|
||||||
|
als datengetriebene **Kombinations-Mechanik** — ein Rezept kann Bedingungen
|
||||||
|
prüfen, Items verbrauchen, ein Item erzeugen, Flags setzen und einen Text
|
||||||
|
ausgeben. Deckt sowohl Transform (`matches + torch → lit_torch`) als auch
|
||||||
|
Anwenden-auf-Ziel (`use key on door → effekt`) mit *einem* Modell ab.
|
||||||
|
|
||||||
|
Bestätigte Entscheidungen (Brainstorming 2026-06-01):
|
||||||
|
- **Allgemeine, datengetriebene Rezepte** (requires / consume / produce / effects
|
||||||
|
/ response / failText) — Superset, nutzt die vorhandene Engine.
|
||||||
|
- **Operanden** lösen aus **Inventar oder aktuellem Raum** auf (`use key on door`,
|
||||||
|
`use matches on torch`).
|
||||||
|
- **Fehlschlag**: kein Rezept → generische Zeile; Rezept vorhanden aber `requires`
|
||||||
|
nicht erfüllt → optionales `failText` (Hinweis), sonst generische Zeile.
|
||||||
|
- **Demo**: ein minimales Showcase-Rezept (`matches + torch → lit_torch`) plus die
|
||||||
|
wenigen dafür nötigen Items, in bestehende Räume gestellt.
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
**In Scope:**
|
||||||
|
- `UseCommand`-Routing: 0 Args → "Use what?"; 1 Arg → bestehendes Einzel-`use`;
|
||||||
|
≥2 Args → Kombination der ersten beiden Operanden.
|
||||||
|
- `Combination`-Datenmodell + optionale `combinations.yaml` (geladen wie
|
||||||
|
quests/endings über `readListOptional`).
|
||||||
|
- `CombinationDto` + Factory + `World.combinations` (Map mit kanonischem Paar-Key).
|
||||||
|
- `Combinations`-Engine (`game`) — Auflösung, Lookup, requires/consume/produce/
|
||||||
|
effects/response/failText.
|
||||||
|
- `WorldValidator`-Regeln: `a`/`b`/`consume`/`produce` referenzieren existierende
|
||||||
|
Item-Ids; keine doppelten Paare.
|
||||||
|
- Minimales Demo-Rezept + Items.
|
||||||
|
- Tests: Engine, UseCommand-Routing, Loader, Validator.
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Parser-Änderung — `on`/`with`/`to` sind **bereits** Filler (s. §3); keine neue
|
||||||
|
Grammatik nötig.
|
||||||
|
- Mehrteilige (>2) Rezepte / Kombination aus 3+ Items.
|
||||||
|
- Mehrwort-Item-Namen-Auflösung (Operanden lösen per Item-Id auf, wie heute).
|
||||||
|
- Größerer Content-/Rätsel-Ausbau (eigenes Teilprojekt).
|
||||||
|
|
||||||
|
## 3. Grammatik & Parsing (keine Änderung nötig)
|
||||||
|
|
||||||
|
`CommandParser.FILLERS` enthält bereits `to`, `with`, `on`, `at`, `the`, `a`,
|
||||||
|
`an`. Daher liefern alle drei Eingaben dieselbe Args-Liste:
|
||||||
|
|
||||||
|
```
|
||||||
|
use matches on torch → verb=use, args=[matches, torch]
|
||||||
|
use matches with torch → verb=use, args=[matches, torch]
|
||||||
|
use matches torch → verb=use, args=[matches, torch]
|
||||||
|
```
|
||||||
|
|
||||||
|
`UseCommand` verzweigt nur über die Arg-Anzahl:
|
||||||
|
|
||||||
|
```java
|
||||||
|
if (args.isEmpty()) { io.write("Use what?"); return; }
|
||||||
|
if (args.size() >= 2) { Combinations.tryUse(ctx, args.get(0), args.get(1)); return; }
|
||||||
|
// 1 Arg: bestehende Einzel-use-Logik (Inventar dann Raum, item.use(ctx))
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei ≥2 Args zählen die **ersten beiden** Tokens als Operandenpaar.
|
||||||
|
|
||||||
|
## 4. Datenmodell (model)
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record Combination(
|
||||||
|
String a, String b, // Operanden-Item-Ids (ungeordnet)
|
||||||
|
List<Condition> requires, // optional; leer = immer erfüllt
|
||||||
|
List<String> consume, // Item-Ids, die entfernt werden
|
||||||
|
String produce, // optional; Item-Id, die ins Inventar kommt (null = keins)
|
||||||
|
List<Effect> effects, // optional; Flag-Mutationen
|
||||||
|
String response, // Erfolgstext
|
||||||
|
String failText // optional; Hinweis bei nicht erfüllten requires
|
||||||
|
) {
|
||||||
|
public Combination {
|
||||||
|
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||||
|
consume = consume == null ? List.of() : List.copyOf(consume);
|
||||||
|
effects = effects == null ? List.of() : List.copyOf(effects);
|
||||||
|
}
|
||||||
|
/** Kanonischer, reihenfolge-unabhängiger Schlüssel für ein Item-Paar. */
|
||||||
|
public static String key(String x, String y) {
|
||||||
|
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
||||||
|
}
|
||||||
|
public String key() { return key(a, b); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`combinations.yaml` (Demo):
|
||||||
|
```yaml
|
||||||
|
- a: matches
|
||||||
|
b: torch
|
||||||
|
consume: [matches]
|
||||||
|
produce: lit_torch
|
||||||
|
response: |
|
||||||
|
You strike the matches; the torch catches with a low hiss and steady glow.
|
||||||
|
# requires/effects/failText optional, hier nicht nötig
|
||||||
|
```
|
||||||
|
|
||||||
|
`World` erhält `Map<String, Combination> combinations` (Key = `Combination.key()`).
|
||||||
|
Backward-kompatible Konstruktor-Überladung wie bei quests/endings.
|
||||||
|
|
||||||
|
## 5. Kombinations-Engine (game/Combinations.java)
|
||||||
|
|
||||||
|
Statische Utility wie `Conditions`/`Effects`/`Light`:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
|
Player p = ctx.getPlayer();
|
||||||
|
// 1. Beide Operanden auflösen (Inventar zuerst, dann aktueller Raum)
|
||||||
|
if (!present(ctx, idX)) { ctx.getIo().write(noSuch(idX)); return; }
|
||||||
|
if (!present(ctx, idY)) { ctx.getIo().write(noSuch(idY)); return; }
|
||||||
|
// 2. Rezept-Lookup über kanonischen Key
|
||||||
|
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
|
||||||
|
if (c == null) { ctx.getIo().write("Nothing happens."); return; }
|
||||||
|
// 3. requires prüfen
|
||||||
|
if (!Conditions.all(c.requires(), ctx)) {
|
||||||
|
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 4. consume → produce → effects → response
|
||||||
|
for (String id : c.consume()) { removeFromAnywhere(ctx, id); }
|
||||||
|
if (c.produce() != null) {
|
||||||
|
Item produced = ctx.getWorld().getItems().get(c.produce());
|
||||||
|
p.addItem(produced);
|
||||||
|
}
|
||||||
|
Effects.applyAll(c.effects(), ctx);
|
||||||
|
ctx.getIo().print(/* response als StyledText */);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Hilfsmethoden:
|
||||||
|
- `present(ctx, id)` → Inventar **oder** aktueller Raum enthält `id`.
|
||||||
|
- `removeFromAnywhere(ctx, id)` → `player.removeItem(id)`, sonst
|
||||||
|
`currentRoom.removeItem(id)`.
|
||||||
|
- `noSuch(id)` → "There is no '<id>' here or in your inventory." (wie UseCommand).
|
||||||
|
- Erzeugtes Item ist in `items.yaml` definiert, aber in **keinem** Raum platziert
|
||||||
|
(existiert in `world.getItems()`, „nirgends"), bis es gefertigt wird → dann ins
|
||||||
|
Inventar. Save/Load erfasst Item-Platzierung + Switch-Zustände bereits korrekt;
|
||||||
|
ungefertigte Items bleiben unplatziert.
|
||||||
|
|
||||||
|
## 6. Loader-Anbindung
|
||||||
|
|
||||||
|
- `CombinationDto` (Jackson-Record): `a, b, requires, consume, produce, effects,
|
||||||
|
response, failText`. `requires`/`effects` via `ConditionDto.toModelList` /
|
||||||
|
`EffectDto.toModelList` (vorhanden).
|
||||||
|
- `WorldLoader`: `readListOptional(basePath + "/combinations.yaml", CombinationDto.class)`.
|
||||||
|
- `CombinationFactory`: DTO → `Combination`; sammelt in `Map<String, Combination>`
|
||||||
|
per `Combination.key()`. Doppelter Key → `WorldLoadException`.
|
||||||
|
- `World.combinations` + Getter; backward-kompatible Konstruktoren.
|
||||||
|
|
||||||
|
## 7. Validierung (WorldValidator)
|
||||||
|
|
||||||
|
- `a`, `b`, jede `consume`-Id und (falls gesetzt) `produce` müssen in der
|
||||||
|
Item-Registry existieren → sonst `WorldLoadException` mit klarer Meldung.
|
||||||
|
- Doppelte Paar-Keys → Fehler (im Factory bereits abgefangen; Validator
|
||||||
|
dokumentiert die Regel).
|
||||||
|
- `requires`/`effects` nutzen die bestehende Condition/Effect-Validierung, falls
|
||||||
|
vorhanden; sonst rein strukturell.
|
||||||
|
|
||||||
|
## 8. Demo-Content (minimal)
|
||||||
|
|
||||||
|
- `items.yaml`: `matches` (plain), `torch` (plain, `light: false`), `lit_torch`
|
||||||
|
(**plain mit `light: true`**). `Light.isActiveLight` wertet ein Nicht-Switchable
|
||||||
|
mit `light: true` als **immer aktive** Lichtquelle (nur SwitchableItems müssen
|
||||||
|
„on" sein) — also ist `lit_torch` eine dauerhaft brennende Lichtquelle, ohne
|
||||||
|
Toggle. `lit_torch` wird **nicht** in einem Raum platziert (entsteht nur durchs
|
||||||
|
Rezept).
|
||||||
|
- `rooms.yaml`: `matches` und `torch` in erreichbare Räume legen.
|
||||||
|
- `combinations.yaml`: das eine Rezept aus §4.
|
||||||
|
- Spielbar nachweisbar: `take matches`, `take torch`, `use matches on torch` →
|
||||||
|
`lit_torch` im Inventar (Lichtquelle), `matches` verbraucht.
|
||||||
|
|
||||||
|
## 9. Tests
|
||||||
|
|
||||||
|
- **`CombinationsTest`**: ungeordneter Match (`X on Y` == `Y on X`); `requires`
|
||||||
|
erfüllt/nicht erfüllt (failText vs. generisch); consume aus Inventar **und** aus
|
||||||
|
Raum; produce landet im Inventar; effects gesetzt; kein Rezept → "Nothing
|
||||||
|
happens."; fehlender Operand → noSuch-Meldung.
|
||||||
|
- **`UseCommand`-Routing**: 0 Args → "Use what?"; 1 Arg → `item.use` (bestehend);
|
||||||
|
2 Args → Combinations-Pfad (über Fake-/TestIO + kleine Welt).
|
||||||
|
- **Loader**: `combinations.yaml` → `World.combinations` korrekt; Key-Kanonisierung;
|
||||||
|
doppeltes Paar → Exception.
|
||||||
|
- **Validator**: unbekannte `a`/`b`/`consume`/`produce`-Id → `WorldLoadException`.
|
||||||
|
- Konvention: keine GUI-/YAML-abhängigen Domain-Tests; Engine-Tests bauen Welt
|
||||||
|
inline wie `QuestEngineTest`.
|
||||||
|
|
||||||
|
## 10. Architektur-Werte / Risiken
|
||||||
|
|
||||||
|
- **Datengetrieben**: Rezepte in YAML, kein Hardcoding; gleiche DTO/Domain-Trennung
|
||||||
|
wie quests/endings.
|
||||||
|
- **Geringe Kopplung**: neue `Combinations`-Utility neben `Conditions`/`Effects`;
|
||||||
|
`UseCommand` bekommt nur eine Verzweigung. Kein Parser-Eingriff.
|
||||||
|
- **Reihenfolge-Unabhängigkeit** über kanonischen Key vermeidet doppelte Rezepte.
|
||||||
|
- **Erzeugte Items** „aus dem Nichts": müssen in `items.yaml` definiert, aber
|
||||||
|
unplatziert sein — Validator stellt Existenz sicher; Save/Load erfasst sie nach
|
||||||
|
dem Fertigen automatisch.
|
||||||
|
- Risiko: `produce` eines bereits irgendwo platzierten Items würde dieselbe Instanz
|
||||||
|
ins Inventar verschieben (Items sind Singletons). Demo vermeidet das (unplatziert).
|
||||||
|
Validator/Doku weist darauf hin.
|
||||||
@@ -49,6 +49,18 @@
|
|||||||
<version>${logback.version}</version>
|
<version>${logback.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OGG Vorbis decoding (GUI background music) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>vorbisspi</artifactId>
|
||||||
|
<version>1.0.3.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.soundlibs</groupId>
|
||||||
|
<artifactId>jorbis</artifactId>
|
||||||
|
<version>0.0.17.4</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ import thb.jeanluc.adventure.command.impl.UseCommand;
|
|||||||
import thb.jeanluc.adventure.game.Game;
|
import thb.jeanluc.adventure.game.Game;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.GameSession;
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.game.TutorialGuide;
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
import thb.jeanluc.adventure.io.GameIO;
|
import thb.jeanluc.adventure.io.GameIO;
|
||||||
import thb.jeanluc.adventure.io.text.Banner;
|
import thb.jeanluc.adventure.io.text.Banner;
|
||||||
|
import thb.jeanluc.adventure.loader.TutorialLoader;
|
||||||
import thb.jeanluc.adventure.loader.WorldLoader;
|
import thb.jeanluc.adventure.loader.WorldLoader;
|
||||||
import thb.jeanluc.adventure.menu.MainMenu;
|
import thb.jeanluc.adventure.menu.MainMenu;
|
||||||
import thb.jeanluc.adventure.menu.SettingsMenu;
|
import thb.jeanluc.adventure.menu.SettingsMenu;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
import thb.jeanluc.adventure.save.SaveException;
|
import thb.jeanluc.adventure.save.SaveException;
|
||||||
import thb.jeanluc.adventure.save.SaveService;
|
import thb.jeanluc.adventure.save.SaveService;
|
||||||
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
import thb.jeanluc.adventure.save.SaveSlotInfo;
|
||||||
@@ -64,24 +67,23 @@ public final class App {
|
|||||||
SaveService saves = new SaveService();
|
SaveService saves = new SaveService();
|
||||||
SettingsStore settingsStore = new SettingsStore();
|
SettingsStore settingsStore = new SettingsStore();
|
||||||
Settings settings = settingsStore.load();
|
Settings settings = settingsStore.load();
|
||||||
ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;
|
SettingsMenu.apply(settings, io);
|
||||||
SettingsMenu.apply(settings, consoleIo);
|
|
||||||
|
|
||||||
boolean running = true;
|
boolean running = true;
|
||||||
while (running) {
|
while (running) {
|
||||||
switch (MainMenu.show(io)) {
|
switch (MainMenu.show(io)) {
|
||||||
case NEW_GAME -> play(io, saves, newSession(io));
|
case NEW_GAME -> play(io, saves, newSession(io), true);
|
||||||
case LOAD -> {
|
case LOAD -> {
|
||||||
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
|
||||||
if (slot != null) {
|
if (slot != null) {
|
||||||
try {
|
try {
|
||||||
play(io, saves, saves.load(slot.slug()));
|
play(io, saves, saves.load(slot.slug()), false);
|
||||||
} catch (SaveException e) {
|
} catch (SaveException e) {
|
||||||
io.write("Could not load: " + e.getUserMessage());
|
io.write("Could not load: " + e.getUserMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo);
|
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);
|
||||||
case QUIT -> running = false;
|
case QUIT -> running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,7 @@ public final class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the registry, runs one game to completion, autosaving to its slot. */
|
/** Builds the registry, runs one game to completion, autosaving to its slot. */
|
||||||
private static void play(GameIO io, SaveService saves, GameSession session) {
|
private static void play(GameIO io, SaveService saves, GameSession session, boolean withTutorial) {
|
||||||
GameContext ctx = new GameContext(session, io);
|
GameContext ctx = new GameContext(session, io);
|
||||||
ctx.setSaveCallback(() -> {
|
ctx.setSaveCallback(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -128,10 +130,18 @@ public final class App {
|
|||||||
registry.register(menu, "menu", "quit", "exit");
|
registry.register(menu, "menu", "quit", "exit");
|
||||||
registry.register(new HelpCommand(registry), "help", "?");
|
registry.register(new HelpCommand(registry), "help", "?");
|
||||||
|
|
||||||
|
if (withTutorial) {
|
||||||
|
Tutorial tutorial = new TutorialLoader().load();
|
||||||
|
if (!tutorial.isEmpty()) {
|
||||||
|
game.setTutorialGuide(new TutorialGuide(tutorial, registry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
io.print(Banner.welcome(session.getWorld().getTitle()));
|
io.print(Banner.welcome(session.getWorld().getTitle()));
|
||||||
io.write(session.getWorld().getWelcomeMessage());
|
io.write(session.getWorld().getWelcomeMessage());
|
||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
|
||||||
game.run();
|
game.run();
|
||||||
|
io.setMusic(null); // fade music out when returning to the menu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,38 +4,48 @@ import thb.jeanluc.adventure.command.Command;
|
|||||||
import thb.jeanluc.adventure.game.Conditions;
|
import thb.jeanluc.adventure.game.Conditions;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.game.Light;
|
import thb.jeanluc.adventure.game.Light;
|
||||||
|
import thb.jeanluc.adventure.game.Pathfinder;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
import thb.jeanluc.adventure.model.ExitLock;
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves the player into the room reachable in a given direction.
|
* Moves the player. {@code go <direction>} steps once to a connected room.
|
||||||
* Usage: {@code go <direction>}.
|
* {@code go to <room>} auto-walks the shortest path (BFS) over already-visited
|
||||||
|
* rooms, honouring locked exits and darkness. Usage: {@code go <direction>} or
|
||||||
|
* {@code go to <room>}. The parser strips the filler word "to", so "go to
|
||||||
|
* <room>" and "go <room>" are equivalent.
|
||||||
*/
|
*/
|
||||||
public class GoCommand implements Command {
|
public class GoCommand implements Command {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void execute(GameContext ctx, List<String> args) {
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
if (args.isEmpty()) {
|
if (args.isEmpty()) {
|
||||||
ctx.getIo().write("Go where? Try 'go north'.");
|
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Direction dir;
|
|
||||||
try {
|
try {
|
||||||
dir = Direction.fromString(args.getFirst());
|
Direction dir = Direction.fromString(args.getFirst());
|
||||||
} catch (IllegalArgumentException e) {
|
moveDirection(ctx, dir);
|
||||||
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
|
} catch (IllegalArgumentException notADirection) {
|
||||||
return;
|
goToRoom(ctx, args);
|
||||||
}
|
}
|
||||||
Optional<Room> next = ctx.getPlayer().getCurrentRoom().getExit(dir);
|
}
|
||||||
|
|
||||||
|
/** Single-step move in a compass direction (the original behaviour). */
|
||||||
|
private void moveDirection(GameContext ctx, Direction dir) {
|
||||||
|
Room here = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Optional<Room> next = here.getExit(dir);
|
||||||
if (next.isEmpty()) {
|
if (next.isEmpty()) {
|
||||||
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir);
|
ExitLock lock = here.getExitLocks().get(dir);
|
||||||
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
ctx.getIo().write(lock.blocked());
|
ctx.getIo().write(lock.blocked());
|
||||||
return;
|
return;
|
||||||
@@ -49,8 +59,70 @@ public class GoCommand implements Command {
|
|||||||
new LookCommand().execute(ctx, List.of());
|
new LookCommand().execute(ctx, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a visited room by id/name and auto-walk the BFS path to it. */
|
||||||
|
private void goToRoom(GameContext ctx, List<String> args) {
|
||||||
|
String name = String.join(" ", args).toLowerCase();
|
||||||
|
Room target = resolveVisited(ctx, name);
|
||||||
|
if (target == null) {
|
||||||
|
ctx.getIo().write("You don't know any direction or place called '" + name + "'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
if (target == start) {
|
||||||
|
ctx.getIo().write("You're already in the " + target.getName() + ".");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> path = Pathfinder.findPath(ctx, target);
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
ctx.getIo().write("You can't find a way there right now.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<Room> route = new ArrayList<>();
|
||||||
|
route.add(start);
|
||||||
|
route.addAll(path);
|
||||||
|
for (Room step : path) {
|
||||||
|
ctx.getPlayer().setCurrentRoom(step);
|
||||||
|
ctx.getIo().travelStep(MapLayout.compute(
|
||||||
|
ctx.getWorld(), ctx.getPlayer().getVisitedRoomIds(), step));
|
||||||
|
}
|
||||||
|
ctx.getIo().write(summary(route));
|
||||||
|
new LookCommand().execute(ctx, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds a visited room whose id or display name matches {@code name}. */
|
||||||
|
private Room resolveVisited(GameContext ctx, String name) {
|
||||||
|
// the command test harness (CommandTestSupport) uses a null world; production always has one
|
||||||
|
if (ctx.getWorld() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String id : ctx.getPlayer().getVisitedRoomIds()) {
|
||||||
|
Room r = ctx.getWorld().getRooms().get(id);
|
||||||
|
if (r == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (id.equalsIgnoreCase(name) || r.getName().equalsIgnoreCase(name)) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names start, destination, and at most one middle waypoint. */
|
||||||
|
private String summary(List<Room> route) {
|
||||||
|
int n = route.size();
|
||||||
|
String start = route.get(0).getName();
|
||||||
|
String dest = route.get(n - 1).getName();
|
||||||
|
if (n >= 3) {
|
||||||
|
String middle = route.get(n / 2).getName();
|
||||||
|
return "You make your way from the " + start + ", through the " + middle
|
||||||
|
+ ", to the " + dest + ".";
|
||||||
|
}
|
||||||
|
return "You make your way from the " + start + " to the " + dest + ".";
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "go <direction> - move to the connected room (north/south/east/west)";
|
return "go <direction> - move one room (north/south/east/west); "
|
||||||
|
+ "or go to <room> to travel to a known room";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.command.impl;
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.command.Command;
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
import thb.jeanluc.adventure.game.GameContext;
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
@@ -8,9 +9,10 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the item-specific {@link Item#use(GameContext)} action. The
|
* With one argument, invokes the item-specific {@link Item#use(GameContext)}
|
||||||
* item must either be in the player's inventory or in the current room.
|
* action; the item must be in the player's inventory or the current room.
|
||||||
* Usage: {@code use <item-id>}.
|
* With two arguments ({@code use <item> on <item>}), delegates to the
|
||||||
|
* combination engine. Usage: {@code use <item>} or {@code use <item> on <item>}.
|
||||||
*/
|
*/
|
||||||
public class UseCommand implements Command {
|
public class UseCommand implements Command {
|
||||||
|
|
||||||
@@ -20,6 +22,10 @@ public class UseCommand implements Command {
|
|||||||
ctx.getIo().write("Use what?");
|
ctx.getIo().write("Use what?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (args.size() >= 2) {
|
||||||
|
Combinations.tryUse(ctx, args.get(0), args.get(1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
String itemId = args.getFirst();
|
String itemId = args.getFirst();
|
||||||
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
Optional<Item> item = ctx.getPlayer().findItem(itemId);
|
||||||
if (item.isEmpty()) {
|
if (item.isEmpty()) {
|
||||||
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String help() {
|
public String help() {
|
||||||
return "use <item> - use an item (read it, switch it on/off, ...)";
|
return "use <item> - use an item; or use <item> on <item> to combine them";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies {@code use X on Y} item combinations. Stateless utility, mirroring
|
||||||
|
* {@link Conditions}/{@link Effects}/{@link Light}. Operands resolve from the
|
||||||
|
* player's inventory or the current room.
|
||||||
|
*/
|
||||||
|
public final class Combinations {
|
||||||
|
|
||||||
|
private Combinations() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to combine the two named items. Prints a resolution error if an
|
||||||
|
* operand is absent, the recipe's {@code failText} (or a generic line) if a
|
||||||
|
* recipe's requirements are unmet, "Nothing happens." if there is no recipe,
|
||||||
|
* or applies consume/produce/effects + the response on success.
|
||||||
|
*/
|
||||||
|
public static void tryUse(GameContext ctx, String idX, String idY) {
|
||||||
|
if (!present(ctx, idX)) {
|
||||||
|
ctx.getIo().write(noSuch(idX));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present(ctx, idY)) {
|
||||||
|
ctx.getIo().write(noSuch(idY));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Combination c = ctx.getWorld().getCombinations().get(Combination.key(idX, idY));
|
||||||
|
if (c == null) {
|
||||||
|
ctx.getIo().write("Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Conditions.all(c.requires(), ctx)) {
|
||||||
|
ctx.getIo().write(c.failText() != null ? c.failText() : "Nothing happens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String id : c.consume()) {
|
||||||
|
removeFromAnywhere(ctx, id);
|
||||||
|
}
|
||||||
|
if (c.produce() != null) {
|
||||||
|
Item produced = ctx.getWorld().getItems().get(c.produce());
|
||||||
|
if (produced != null) {
|
||||||
|
ctx.getPlayer().addItem(produced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Effects.applyAll(c.effects(), ctx);
|
||||||
|
if (c.response() != null && !c.response().isBlank()) {
|
||||||
|
ctx.getIo().write(c.response());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean present(GameContext ctx, String id) {
|
||||||
|
return ctx.getPlayer().hasItem(id)
|
||||||
|
|| ctx.getPlayer().getCurrentRoom().findItem(id).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeFromAnywhere(GameContext ctx, String id) {
|
||||||
|
if (ctx.getPlayer().removeItem(id).isEmpty()) {
|
||||||
|
ctx.getPlayer().getCurrentRoom().removeItem(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String noSuch(String id) {
|
||||||
|
return "There is no '" + id + "' here or in your inventory.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,23 @@ public class Game {
|
|||||||
/** Loop flag. Flipped to false by {@link #stop()}. */
|
/** Loop flag. Flipped to false by {@link #stop()}. */
|
||||||
private boolean running = true;
|
private boolean running = true;
|
||||||
|
|
||||||
|
/** Optional onboarding guide; null in normal/loaded play. */
|
||||||
|
private TutorialGuide tutorialGuide;
|
||||||
|
|
||||||
|
/** Wires the interactive tutorial (New Game only). */
|
||||||
|
public void setTutorialGuide(TutorialGuide tutorialGuide) {
|
||||||
|
this.tutorialGuide = tutorialGuide;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
* Runs the loop until {@link #stop()} is called or input is exhausted.
|
||||||
*/
|
*/
|
||||||
public void run() {
|
public void run() {
|
||||||
publishHud();
|
publishHud();
|
||||||
maybeEnd();
|
maybeEnd();
|
||||||
|
if (tutorialGuide != null) {
|
||||||
|
tutorialGuide.begin(ctx);
|
||||||
|
}
|
||||||
while (running) {
|
while (running) {
|
||||||
String input = ctx.getIo().readLine();
|
String input = ctx.getIo().readLine();
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -47,6 +58,12 @@ public class Game {
|
|||||||
if (parsed.verb().isEmpty()) {
|
if (parsed.verb().isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()
|
||||||
|
&& parsed.verb().equals("skip")) {
|
||||||
|
tutorialGuide.skip(ctx);
|
||||||
|
publishHud();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
Optional<Command> cmd = registry.find(parsed.verb());
|
Optional<Command> cmd = registry.find(parsed.verb());
|
||||||
if (cmd.isEmpty()) {
|
if (cmd.isEmpty()) {
|
||||||
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
|
||||||
@@ -56,6 +73,9 @@ public class Game {
|
|||||||
if (ctx.getSession().getTurn() % 10 == 0) {
|
if (ctx.getSession().getTurn() % 10 == 0) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
}
|
}
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()) {
|
||||||
|
tutorialGuide.onCommand(ctx, parsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
publishHud();
|
publishHud();
|
||||||
maybeEnd();
|
maybeEnd();
|
||||||
@@ -64,6 +84,11 @@ public class Game {
|
|||||||
|
|
||||||
/** Ends the game if any ending's conditions are met, printing its screen. */
|
/** Ends the game if any ending's conditions are met, printing its screen. */
|
||||||
private void maybeEnd() {
|
private void maybeEnd() {
|
||||||
|
// Don't end the game (win or lose) while the player is still in the tutorial —
|
||||||
|
// a pending ending fires once the walkthrough finishes or is skipped.
|
||||||
|
if (tutorialGuide != null && tutorialGuide.isActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Ending e = EndingEngine.triggered(ctx);
|
Ending e = EndingEngine.triggered(ctx);
|
||||||
if (e != null) {
|
if (e != null) {
|
||||||
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
|
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
|
||||||
@@ -84,6 +109,7 @@ public class Game {
|
|||||||
ctx.getPlayer().getCurrentRoom());
|
ctx.getPlayer().getCurrentRoom());
|
||||||
ctx.getIo().setMap(map);
|
ctx.getIo().setMap(map);
|
||||||
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
|
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
|
||||||
|
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breadth-first shortest-path search over the rooms the player has already
|
||||||
|
* visited, honouring the same gates as a manual move: locked exits and
|
||||||
|
* darkness. Stateless utility, alongside {@link Conditions}/{@link Effects}/
|
||||||
|
* {@link Light}.
|
||||||
|
*/
|
||||||
|
public final class Pathfinder {
|
||||||
|
|
||||||
|
private Pathfinder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the shortest path from the player's current room to {@code target}.
|
||||||
|
*
|
||||||
|
* @param ctx active game context
|
||||||
|
* @param target destination room (expected to be a visited room)
|
||||||
|
* @return the rooms to step through, excluding the start and ending at
|
||||||
|
* {@code target}; empty if there is no currently-passable path
|
||||||
|
* (or {@code target} is the current room)
|
||||||
|
*/
|
||||||
|
public static List<Room> findPath(GameContext ctx, Room target) {
|
||||||
|
Room start = ctx.getPlayer().getCurrentRoom();
|
||||||
|
Set<String> visited = ctx.getPlayer().getVisitedRoomIds();
|
||||||
|
|
||||||
|
Queue<Room> queue = new ArrayDeque<>();
|
||||||
|
Map<Room, Room> cameFrom = new HashMap<>();
|
||||||
|
queue.add(start);
|
||||||
|
cameFrom.put(start, null);
|
||||||
|
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
Room current = queue.poll();
|
||||||
|
if (current == target) {
|
||||||
|
return reconstruct(cameFrom, target);
|
||||||
|
}
|
||||||
|
for (Map.Entry<Direction, Room> exit : current.getExits().entrySet()) {
|
||||||
|
Room next = exit.getValue();
|
||||||
|
if (cameFrom.containsKey(next) || !visited.contains(next.getId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ExitLock lock = current.getExitLocks().get(exit.getKey());
|
||||||
|
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Light.isLit(ctx, next)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cameFrom.put(next, current);
|
||||||
|
queue.add(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks predecessors back from target to start; returns steps after start. */
|
||||||
|
private static List<Room> reconstruct(Map<Room, Room> cameFrom, Room target) {
|
||||||
|
LinkedList<Room> path = new LinkedList<>();
|
||||||
|
for (Room r = target; r != null && cameFrom.get(r) != null; r = cameFrom.get(r)) {
|
||||||
|
path.addFirst(r);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||||
|
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the interactive start-of-game walkthrough, layered onto the real game
|
||||||
|
* loop. Prints an instruction, then on each executed command decides whether the
|
||||||
|
* current step is satisfied (confirm + advance) or not (hint). Movement steps
|
||||||
|
* require an actual room change. Alias-aware via the {@link CommandRegistry}.
|
||||||
|
*/
|
||||||
|
public final class TutorialGuide {
|
||||||
|
|
||||||
|
private final Tutorial tutorial;
|
||||||
|
private final CommandRegistry registry;
|
||||||
|
private int index;
|
||||||
|
private boolean active;
|
||||||
|
private boolean begun;
|
||||||
|
private Room roomAtStepStart;
|
||||||
|
|
||||||
|
public TutorialGuide(Tutorial tutorial, CommandRegistry registry) {
|
||||||
|
this.tutorial = tutorial;
|
||||||
|
this.registry = registry;
|
||||||
|
this.active = !tutorial.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true while the walkthrough is still running. */
|
||||||
|
public boolean isActive() {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prints the intro and the first instruction (call once at loop start). */
|
||||||
|
public void begin(GameContext ctx) {
|
||||||
|
if (!active || begun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
begun = true;
|
||||||
|
if (tutorial.intro() != null && !tutorial.intro().isBlank()) {
|
||||||
|
ctx.getIo().write(tutorial.intro());
|
||||||
|
}
|
||||||
|
enterStep(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evaluates the current step against the command that was just executed. */
|
||||||
|
public void onCommand(GameContext ctx, ParsedCommand parsed) {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TutorialStep step = tutorial.steps().get(index);
|
||||||
|
if (satisfies(step, ctx, parsed)) {
|
||||||
|
ctx.getIo().write(step.confirm());
|
||||||
|
index++;
|
||||||
|
if (index >= tutorial.steps().size()) {
|
||||||
|
if (tutorial.closingTips() != null && !tutorial.closingTips().isBlank()) {
|
||||||
|
ctx.getIo().write(tutorial.closingTips());
|
||||||
|
}
|
||||||
|
active = false;
|
||||||
|
} else {
|
||||||
|
enterStep(ctx);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.getIo().write(step.hint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the tutorial early (the 'skip' command). */
|
||||||
|
public void skip(GameContext ctx) {
|
||||||
|
if (active) {
|
||||||
|
ctx.getIo().write("Tutorial skipped.");
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enterStep(GameContext ctx) {
|
||||||
|
roomAtStepStart = ctx.getPlayer().getCurrentRoom();
|
||||||
|
ctx.getIo().write(tutorial.steps().get(index).instruction());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean satisfies(TutorialStep step, GameContext ctx, ParsedCommand parsed) {
|
||||||
|
String expect = step.expect();
|
||||||
|
if ("go_direction".equals(expect)) {
|
||||||
|
return isGo(parsed) && firstIsDirection(parsed) && moved(ctx);
|
||||||
|
}
|
||||||
|
if ("go_to_room".equals(expect)) {
|
||||||
|
return isGo(parsed) && !firstIsDirection(parsed) && moved(ctx);
|
||||||
|
}
|
||||||
|
return sameCommand(parsed.verb(), expect) && parsed.args().size() >= step.minArgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isGo(ParsedCommand p) {
|
||||||
|
return sameCommand(p.verb(), "go") && !p.args().isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean firstIsDirection(ParsedCommand p) {
|
||||||
|
if (p.args().isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Direction.fromString(p.args().get(0));
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean moved(GameContext ctx) {
|
||||||
|
return roomAtStepStart != null
|
||||||
|
&& ctx.getPlayer().getCurrentRoom() != roomAtStepStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameCommand(String a, String b) {
|
||||||
|
Optional<Command> ca = registry.find(a);
|
||||||
|
Optional<Command> cb = registry.find(b);
|
||||||
|
return ca.isPresent() && cb.isPresent() && ca.get() == cb.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,17 @@ public interface GameIO {
|
|||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One frame of an auto-walk (the {@code go to} command), as the player moves
|
||||||
|
* through a room en route. Default is a no-op: text mode travels silently.
|
||||||
|
* The GUI overrides this to repaint the map and pace the animation.
|
||||||
|
*
|
||||||
|
* @param view the map snapshot at this step
|
||||||
|
*/
|
||||||
|
default void travelStep(MapView view) {
|
||||||
|
// no animation in text mode
|
||||||
|
}
|
||||||
|
|
||||||
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
|
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
|
||||||
default void showMap(MapView view) {
|
default void showMap(MapView view) {
|
||||||
print(AsciiMap.render(view, true));
|
print(AsciiMap.render(view, true));
|
||||||
@@ -54,6 +65,16 @@ public interface GameIO {
|
|||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
|
||||||
|
default void setMusic(String track) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies the music volume/level. Console no-op; GUI sets it. */
|
||||||
|
default void setMusicLevel(MusicLevel level) {
|
||||||
|
// no audio in text mode
|
||||||
|
}
|
||||||
|
|
||||||
/** On-demand quest log (the 'quests' command). Default renders styled text. */
|
/** On-demand quest log (the 'quests' command). Default renders styled text. */
|
||||||
default void showQuests(QuestView view) {
|
default void showQuests(QuestView view) {
|
||||||
print(QuestText.render(view));
|
print(QuestText.render(view));
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Raw audio operations — the only part that touches sound hardware. */
|
||||||
|
public interface MusicBackend {
|
||||||
|
|
||||||
|
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
|
||||||
|
void fadeTo(String track, float gainDb);
|
||||||
|
|
||||||
|
/** Fade the current track out and stop. */
|
||||||
|
void fadeOut();
|
||||||
|
|
||||||
|
/** Set the playback volume immediately. */
|
||||||
|
void setGainDb(float gainDb);
|
||||||
|
|
||||||
|
/** Stop playback and release resources. */
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
|
||||||
|
* the same track), fades to silence on a null/blank track, and honours the
|
||||||
|
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
|
||||||
|
* {@link MusicBackend}.
|
||||||
|
*/
|
||||||
|
public final class MusicController {
|
||||||
|
|
||||||
|
private final MusicBackend backend;
|
||||||
|
private MusicLevel level = MusicLevel.MEDIUM;
|
||||||
|
private String current; // currently selected track, or null = silence
|
||||||
|
|
||||||
|
public MusicController(MusicBackend backend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called per turn with the current room's music field (may be null/blank). */
|
||||||
|
public void room(String track) {
|
||||||
|
if (!level.isOn()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String next = (track == null || track.isBlank()) ? null : track;
|
||||||
|
if (Objects.equals(next, current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
if (next == null) {
|
||||||
|
backend.fadeOut();
|
||||||
|
} else {
|
||||||
|
backend.fadeTo(next, level.gainDb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies a new music level (volume / off). */
|
||||||
|
public void level(MusicLevel newLevel) {
|
||||||
|
if (newLevel == this.level) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.level = newLevel;
|
||||||
|
if (!newLevel.isOn()) {
|
||||||
|
backend.fadeOut();
|
||||||
|
current = null; // turning back on replays on the next room() call
|
||||||
|
} else {
|
||||||
|
backend.setGainDb(newLevel.gainDb());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
backend.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
/** Background-music volume level, cycled in the Settings menu. */
|
||||||
|
public enum MusicLevel {
|
||||||
|
OFF, LOW, MEDIUM, HIGH;
|
||||||
|
|
||||||
|
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
|
||||||
|
public float gainDb() {
|
||||||
|
return switch (this) {
|
||||||
|
case OFF -> Float.NEGATIVE_INFINITY;
|
||||||
|
case LOW -> -20f;
|
||||||
|
case MEDIUM -> -10f;
|
||||||
|
case HIGH -> 0f;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return true when music should play. */
|
||||||
|
public boolean isOn() {
|
||||||
|
return this != OFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next level in the cycle (wraps HIGH → OFF). */
|
||||||
|
public MusicLevel next() {
|
||||||
|
return values()[(ordinal() + 1) % values().length];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.sound.sampled.AudioFormat;
|
||||||
|
import javax.sound.sampled.AudioInputStream;
|
||||||
|
import javax.sound.sampled.AudioSystem;
|
||||||
|
import javax.sound.sampled.FloatControl;
|
||||||
|
import javax.sound.sampled.SourceDataLine;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams OGG Vorbis tracks from the external {@code music/} folder through a
|
||||||
|
* {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
|
||||||
|
* vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
|
||||||
|
* thread; missing/undecodable files are logged and stay silent. GUI-only —
|
||||||
|
* verified manually.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class OggMusicBackend implements MusicBackend {
|
||||||
|
|
||||||
|
private static final String MUSIC_DIR = "music";
|
||||||
|
private static final int FADE_MS = 400;
|
||||||
|
private static final int FADE_STEPS = 20;
|
||||||
|
private static final int BUFFER = 4096;
|
||||||
|
private static final float FLOOR_DB = -40f; // near-silence for fades
|
||||||
|
|
||||||
|
private volatile Thread playThread;
|
||||||
|
private volatile boolean stopping;
|
||||||
|
private volatile SourceDataLine line;
|
||||||
|
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeTo(String track, float gainDb) {
|
||||||
|
stopCurrent();
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
Path file = Path.of(MUSIC_DIR, track);
|
||||||
|
if (!Files.isReadable(file)) {
|
||||||
|
log.warn("Music file not found, staying silent: {}", file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopping = false;
|
||||||
|
playThread = new Thread(() -> playLoop(file, gainDb), "music");
|
||||||
|
playThread.setDaemon(true);
|
||||||
|
playThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void fadeOut() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void setGainDb(float gainDb) {
|
||||||
|
targetGainDb = gainDb;
|
||||||
|
SourceDataLine l = line;
|
||||||
|
if (l != null) {
|
||||||
|
applyGain(l, gainDb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void shutdown() {
|
||||||
|
stopCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
|
||||||
|
private void stopCurrent() {
|
||||||
|
Thread t = playThread;
|
||||||
|
if (t == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopping = true;
|
||||||
|
try {
|
||||||
|
t.join(FADE_MS + 600L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
playThread = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void playLoop(Path file, float gainDb) {
|
||||||
|
SourceDataLine l = null;
|
||||||
|
try {
|
||||||
|
while (!stopping) {
|
||||||
|
try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
|
||||||
|
AudioFormat base = in.getFormat();
|
||||||
|
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
||||||
|
base.getSampleRate(), 16, base.getChannels(),
|
||||||
|
base.getChannels() * 2, base.getSampleRate(), false);
|
||||||
|
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
|
||||||
|
if (l == null) {
|
||||||
|
l = AudioSystem.getSourceDataLine(pcm);
|
||||||
|
l.open(pcm);
|
||||||
|
l.start();
|
||||||
|
line = l;
|
||||||
|
ramp(l, FLOOR_DB, gainDb); // fade in once
|
||||||
|
}
|
||||||
|
byte[] buf = new byte[BUFFER];
|
||||||
|
int n;
|
||||||
|
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
|
||||||
|
l.write(buf, 0, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Music playback failed for {}: {}", file, e.getMessage());
|
||||||
|
} finally {
|
||||||
|
if (l != null) {
|
||||||
|
try {
|
||||||
|
ramp(l, currentGain(l), FLOOR_DB); // fade out
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// best-effort fade
|
||||||
|
}
|
||||||
|
l.stop();
|
||||||
|
l.close();
|
||||||
|
}
|
||||||
|
line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ramp(SourceDataLine l, float fromDb, float toDb) {
|
||||||
|
for (int i = 0; i <= FADE_STEPS; i++) {
|
||||||
|
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
||||||
|
sleep(FADE_MS / FADE_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private float currentGain(SourceDataLine l) {
|
||||||
|
try {
|
||||||
|
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return targetGainDb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyGain(SourceDataLine l, float db) {
|
||||||
|
try {
|
||||||
|
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
|
||||||
|
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// line has no master-gain control on this platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import javax.swing.Box;
|
|||||||
import javax.swing.BoxLayout;
|
import javax.swing.BoxLayout;
|
||||||
import javax.swing.JButton;
|
import javax.swing.JButton;
|
||||||
import javax.swing.JComponent;
|
import javax.swing.JComponent;
|
||||||
import javax.swing.JDialog;
|
|
||||||
import javax.swing.JFrame;
|
import javax.swing.JFrame;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JPanel;
|
import javax.swing.JPanel;
|
||||||
@@ -22,6 +21,7 @@ import javax.swing.JScrollPane;
|
|||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JTextPane;
|
import javax.swing.JTextPane;
|
||||||
import javax.swing.KeyStroke;
|
import javax.swing.KeyStroke;
|
||||||
|
import javax.swing.SwingConstants;
|
||||||
import javax.swing.SwingUtilities;
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.text.BadLocationException;
|
import javax.swing.text.BadLocationException;
|
||||||
import javax.swing.text.SimpleAttributeSet;
|
import javax.swing.text.SimpleAttributeSet;
|
||||||
@@ -29,10 +29,13 @@ import javax.swing.text.StyleConstants;
|
|||||||
import javax.swing.text.StyledDocument;
|
import javax.swing.text.StyledDocument;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Cursor;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.Font;
|
import java.awt.Font;
|
||||||
import java.awt.GraphicsConfiguration;
|
import java.awt.GraphicsConfiguration;
|
||||||
import java.awt.GraphicsEnvironment;
|
import java.awt.GraphicsEnvironment;
|
||||||
|
import java.awt.GridBagLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
import java.awt.Toolkit;
|
import java.awt.Toolkit;
|
||||||
import java.awt.event.ActionEvent;
|
import java.awt.event.ActionEvent;
|
||||||
@@ -63,11 +66,15 @@ public class SwingIO implements GameIO {
|
|||||||
/** Unscaled size for the HUD and side-panel font, in points. */
|
/** Unscaled size for the HUD and side-panel font, in points. */
|
||||||
private static final float SMALL_SIZE = 12.5f;
|
private static final float SMALL_SIZE = 12.5f;
|
||||||
|
|
||||||
|
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||||
|
private static final long TRAVEL_STEP_MS = 300L;
|
||||||
|
|
||||||
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
|
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
|
||||||
private static final double SIDE_RATIO = 0.28;
|
private static final double SIDE_RATIO = 0.28;
|
||||||
private static final int SIDE_MIN = 180;
|
private static final int SIDE_MIN = 180;
|
||||||
private static final int SIDE_MAX = 460;
|
private static final int SIDE_MAX = 460;
|
||||||
|
|
||||||
|
private final MusicController music = new MusicController(new OggMusicBackend());
|
||||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||||
private final JFrame frame;
|
private final JFrame frame;
|
||||||
private final JTextPane output;
|
private final JTextPane output;
|
||||||
@@ -138,6 +145,7 @@ public class SwingIO implements GameIO {
|
|||||||
public void componentResized(ComponentEvent e) {
|
public void componentResized(ComponentEvent e) {
|
||||||
updateSideWidth();
|
updateSideWidth();
|
||||||
applyFonts();
|
applyFonts();
|
||||||
|
rescaleMenuOverlayIfVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -326,11 +334,31 @@ public class SwingIO implements GameIO {
|
|||||||
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMusic(String track) {
|
||||||
|
music.room(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMusicLevel(MusicLevel level) {
|
||||||
|
music.level(level);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setMap(MapView view) {
|
public void setMap(MapView view) {
|
||||||
map.show(view);
|
map.show(view);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void travelStep(MapView view) {
|
||||||
|
setMap(view);
|
||||||
|
try {
|
||||||
|
Thread.sleep(TRAVEL_STEP_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setQuests(QuestView view) {
|
public void setQuests(QuestView view) {
|
||||||
quests.show(view);
|
quests.show(view);
|
||||||
@@ -348,41 +376,12 @@ public class SwingIO implements GameIO {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int choose(String title, List<String> options) {
|
public int choose(String title, List<String> options) {
|
||||||
// Same EDT↔worker handoff as readLine: the worker blocks on take(),
|
// Full-window overlay (glass pane), not a floating dialog: it covers the game
|
||||||
// the EDT (button click / window close) offers the chosen index.
|
// chrome so no panels/outlines show behind the menu, and it fills + scales with
|
||||||
|
// the window. EDT↔worker handoff as in readLine: the worker blocks on take(),
|
||||||
|
// a button click / Escape offers the chosen index.
|
||||||
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
|
LinkedBlockingQueue<Integer> picked = new LinkedBlockingQueue<>();
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
|
||||||
JDialog dialog = new JDialog(frame, title, true);
|
|
||||||
JPanel panel = new JPanel();
|
|
||||||
panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS));
|
|
||||||
panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12));
|
|
||||||
panel.add(new JLabel(title));
|
|
||||||
panel.add(Box.createVerticalStrut(8));
|
|
||||||
for (int i = 0; i < options.size(); i++) {
|
|
||||||
int idx = i;
|
|
||||||
JButton b = new JButton(options.get(i));
|
|
||||||
b.setAlignmentX(0f);
|
|
||||||
b.addActionListener(e -> {
|
|
||||||
dialog.dispose();
|
|
||||||
picked.offer(idx);
|
|
||||||
});
|
|
||||||
panel.add(b);
|
|
||||||
panel.add(Box.createVerticalStrut(4));
|
|
||||||
}
|
|
||||||
// Closing the dialog counts as the last (safe/cancel) option.
|
|
||||||
dialog.setDefaultCloseOperation(JDialog.DO_NOTHING_ON_CLOSE);
|
|
||||||
dialog.addWindowListener(new java.awt.event.WindowAdapter() {
|
|
||||||
@Override
|
|
||||||
public void windowClosing(java.awt.event.WindowEvent e) {
|
|
||||||
dialog.dispose();
|
|
||||||
picked.offer(options.size() - 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
dialog.setContentPane(panel);
|
|
||||||
dialog.pack();
|
|
||||||
dialog.setLocationRelativeTo(frame);
|
|
||||||
dialog.setVisible(true);
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
return picked.take();
|
return picked.take();
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
@@ -391,6 +390,123 @@ public class SwingIO implements GameIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
|
||||||
|
private String menuTitle;
|
||||||
|
private List<String> menuOptions;
|
||||||
|
private LinkedBlockingQueue<Integer> menuPicked;
|
||||||
|
|
||||||
|
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
|
||||||
|
private void rescaleMenuOverlayIfVisible() {
|
||||||
|
if (menuOptions != null && frame.getGlassPane().isVisible()) {
|
||||||
|
showMenuOverlay(menuTitle, menuOptions, menuPicked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installs the menu as the frame's glass pane (covers everything) and shows it. */
|
||||||
|
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
|
||||||
|
menuTitle = title;
|
||||||
|
menuOptions = options;
|
||||||
|
menuPicked = picked;
|
||||||
|
float scale = frameScale() * zoom; // same scaling the rest of the UI uses
|
||||||
|
|
||||||
|
JPanel glass = new JPanel(new GridBagLayout());
|
||||||
|
glass.setOpaque(true);
|
||||||
|
glass.setBackground(MENU_BG);
|
||||||
|
glass.addMouseListener(new java.awt.event.MouseAdapter() { }); // swallow backdrop clicks
|
||||||
|
|
||||||
|
JPanel card = new JPanel();
|
||||||
|
card.setOpaque(false);
|
||||||
|
card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS));
|
||||||
|
|
||||||
|
JLabel heading = new JLabel(title.toUpperCase());
|
||||||
|
heading.setForeground(MENU_TITLE);
|
||||||
|
heading.setFont(baseFont.deriveFont(Font.BOLD, BASE_SIZE * scale * 1.6f));
|
||||||
|
heading.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||||
|
heading.setBorder(BorderFactory.createEmptyBorder(0, 0, (int) (22 * scale), 0));
|
||||||
|
card.add(heading);
|
||||||
|
|
||||||
|
Runnable cancel = () -> {
|
||||||
|
hideMenuOverlay();
|
||||||
|
picked.offer(options.size() - 1);
|
||||||
|
};
|
||||||
|
for (int i = 0; i < options.size(); i++) {
|
||||||
|
int idx = i;
|
||||||
|
JButton b = menuButton(options.get(i), scale);
|
||||||
|
b.addActionListener(e -> {
|
||||||
|
hideMenuOverlay();
|
||||||
|
picked.offer(idx);
|
||||||
|
});
|
||||||
|
card.add(b);
|
||||||
|
if (i < options.size() - 1) {
|
||||||
|
card.add(Box.createVerticalStrut((int) (10 * scale)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape selects the last (safe/cancel) option — mirrors the console EOF convention.
|
||||||
|
glass.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
|
||||||
|
.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "menuCancel");
|
||||||
|
glass.getActionMap().put("menuCancel", action(e -> cancel.run()));
|
||||||
|
|
||||||
|
glass.add(card); // single child → centered by GridBagLayout
|
||||||
|
frame.setGlassPane(glass);
|
||||||
|
glass.setVisible(true);
|
||||||
|
glass.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void hideMenuOverlay() {
|
||||||
|
menuOptions = null; // stop resize rebuilds once the menu is dismissed
|
||||||
|
frame.getGlassPane().setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Palette for the themed menu overlay, matching the game window's dark look. */
|
||||||
|
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
|
||||||
|
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
|
||||||
|
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
|
||||||
|
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
|
||||||
|
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
|
||||||
|
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
|
||||||
|
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
|
||||||
|
|
||||||
|
/** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
|
||||||
|
private JButton menuButton(String text, float scale) {
|
||||||
|
int padV = (int) (11 * scale);
|
||||||
|
int padH = (int) (18 * scale);
|
||||||
|
JButton b = new JButton(text);
|
||||||
|
b.setFont(baseFont.deriveFont(BASE_SIZE * scale));
|
||||||
|
b.setForeground(BTN_FG);
|
||||||
|
b.setBackground(BTN_BG);
|
||||||
|
b.setFocusPainted(false);
|
||||||
|
b.setOpaque(true);
|
||||||
|
b.setHorizontalAlignment(SwingConstants.LEFT);
|
||||||
|
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||||
|
b.setAlignmentX(Component.CENTER_ALIGNMENT);
|
||||||
|
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
|
||||||
|
Dimension size = new Dimension((int) (340 * scale), b.getPreferredSize().height);
|
||||||
|
b.setPreferredSize(size);
|
||||||
|
b.setMinimumSize(size);
|
||||||
|
b.setMaximumSize(size);
|
||||||
|
b.addMouseListener(new java.awt.event.MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseEntered(java.awt.event.MouseEvent e) {
|
||||||
|
b.setBackground(BTN_BG_HOVER);
|
||||||
|
b.setBorder(menuButtonBorder(BTN_LINE_HOVER, padV, padH));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseExited(java.awt.event.MouseEvent e) {
|
||||||
|
b.setBackground(BTN_BG);
|
||||||
|
b.setBorder(menuButtonBorder(BTN_LINE, padV, padH));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
|
||||||
|
return BorderFactory.createCompoundBorder(
|
||||||
|
BorderFactory.createLineBorder(line, 1),
|
||||||
|
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
|
||||||
|
}
|
||||||
|
|
||||||
private void addList(List<Span> spans, List<String> xs, Style st) {
|
private void addList(List<Span> spans, List<String> xs, Style st) {
|
||||||
for (int i = 0; i < xs.size(); i++) {
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.ConditionDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.EffectDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Builds {@link Combination}s from {@link CombinationDto}s, validating item ids. */
|
||||||
|
public final class CombinationFactory {
|
||||||
|
|
||||||
|
private CombinationFactory() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Combination fromDto(CombinationDto dto, Map<String, Item> items) {
|
||||||
|
requireItem(items, dto.a(), "a");
|
||||||
|
requireItem(items, dto.b(), "b");
|
||||||
|
if (dto.a().equals(dto.b())) {
|
||||||
|
throw new WorldLoadException(
|
||||||
|
"Combination cannot combine an item with itself: '" + dto.a() + "'");
|
||||||
|
}
|
||||||
|
if (dto.consume() != null) {
|
||||||
|
for (String id : dto.consume()) {
|
||||||
|
requireItem(items, id, "consume");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.produce() != null) {
|
||||||
|
requireItem(items, dto.produce(), "produce");
|
||||||
|
}
|
||||||
|
return new Combination(
|
||||||
|
dto.a(),
|
||||||
|
dto.b(),
|
||||||
|
ConditionDto.toModelList(dto.requires()),
|
||||||
|
dto.consume() == null ? List.of() : List.copyOf(dto.consume()),
|
||||||
|
dto.produce(),
|
||||||
|
EffectDto.toModelList(dto.effects()),
|
||||||
|
dto.response(),
|
||||||
|
dto.failText());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireItem(Map<String, Item> items, String id, String field) {
|
||||||
|
if (id == null) {
|
||||||
|
throw new WorldLoadException("Combination '" + field + "' is missing an item id");
|
||||||
|
}
|
||||||
|
if (!items.containsKey(id)) {
|
||||||
|
throw new WorldLoadException(
|
||||||
|
"Combination references unknown item '" + id + "' (field '" + field + "')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ public final class RoomFactory {
|
|||||||
public static Room shellFromDto(RoomDto dto) {
|
public static Room shellFromDto(RoomDto dto) {
|
||||||
Room room = new Room(dto.id(), dto.name(), dto.description());
|
Room room = new Room(dto.id(), dto.name(), dto.description());
|
||||||
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
room.setDark(Boolean.TRUE.equals(dto.dark()));
|
||||||
|
room.setMusic(dto.music());
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.TutorialDto;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.TutorialStepDto;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the optional onboarding tutorial from {@code <base>/tutorial.yaml}.
|
||||||
|
* Kept separate from {@link WorldLoader}: the tutorial is onboarding, not world
|
||||||
|
* content. Returns {@link Tutorial#none()} when the file is absent.
|
||||||
|
*/
|
||||||
|
public class TutorialLoader {
|
||||||
|
|
||||||
|
/** Default classpath base directory. */
|
||||||
|
public static final String DEFAULT_BASE = "/world";
|
||||||
|
|
||||||
|
private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory());
|
||||||
|
private final String basePath;
|
||||||
|
|
||||||
|
public TutorialLoader() {
|
||||||
|
this(DEFAULT_BASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TutorialLoader(String basePath) {
|
||||||
|
this.basePath = basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the tutorial; absent file → {@link Tutorial#none()}. */
|
||||||
|
public Tutorial load() {
|
||||||
|
String resource = basePath + "/tutorial.yaml";
|
||||||
|
try (InputStream in = getClass().getResourceAsStream(resource)) {
|
||||||
|
if (in == null) {
|
||||||
|
return Tutorial.none();
|
||||||
|
}
|
||||||
|
TutorialDto dto = yaml.readValue(in, TutorialDto.class);
|
||||||
|
if (dto == null) {
|
||||||
|
return Tutorial.none();
|
||||||
|
}
|
||||||
|
List<TutorialStep> steps = new ArrayList<>();
|
||||||
|
if (dto.steps() != null) {
|
||||||
|
for (TutorialStepDto s : dto.steps()) {
|
||||||
|
steps.add(new TutorialStep(
|
||||||
|
s.instruction(),
|
||||||
|
s.expect(),
|
||||||
|
s.minArgs() == null ? 0 : s.minArgs(),
|
||||||
|
s.confirm(),
|
||||||
|
s.hint()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Tutorial(dto.intro(), steps, dto.closingTips());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new WorldLoadException("Failed to parse " + resource, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.type.CollectionType;
|
|||||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
import thb.jeanluc.adventure.loader.dto.EndingDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.GameDto;
|
import thb.jeanluc.adventure.loader.dto.GameDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
import thb.jeanluc.adventure.loader.dto.ItemDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
import thb.jeanluc.adventure.loader.dto.NpcDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
import thb.jeanluc.adventure.loader.dto.QuestDto;
|
||||||
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
import thb.jeanluc.adventure.model.Ending;
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
import thb.jeanluc.adventure.model.Npc;
|
import thb.jeanluc.adventure.model.Npc;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
@@ -75,6 +77,8 @@ public class WorldLoader {
|
|||||||
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
|
||||||
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.class);
|
||||||
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
List<EndingDto> endingDtos = readListOptional(basePath + "/endings.yaml", EndingDto.class);
|
||||||
|
List<CombinationDto> combinationDtos =
|
||||||
|
readListOptional(basePath + "/combinations.yaml", CombinationDto.class);
|
||||||
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
GameDto gameDto = readSingle(basePath + "/game.yaml", GameDto.class);
|
||||||
|
|
||||||
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
|
||||||
@@ -104,6 +108,16 @@ public class WorldLoader {
|
|||||||
endings.add(EndingFactory.fromDto(dto));
|
endings.add(EndingFactory.fromDto(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, Combination> combinations = new HashMap<>();
|
||||||
|
for (CombinationDto dto : combinationDtos) {
|
||||||
|
Combination c = CombinationFactory.fromDto(dto, items);
|
||||||
|
String key = c.key();
|
||||||
|
if (combinations.containsKey(key)) {
|
||||||
|
throw new WorldLoadException("Duplicate combination for pair '" + key + "'");
|
||||||
|
}
|
||||||
|
combinations.put(key, c);
|
||||||
|
}
|
||||||
|
|
||||||
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
ReferenceResolver resolver = new ReferenceResolver(items, npcs, rooms);
|
||||||
resolver.resolveRooms(roomDtos);
|
resolver.resolveRooms(roomDtos);
|
||||||
resolver.resolveNpcs(npcDtos);
|
resolver.resolveNpcs(npcDtos);
|
||||||
@@ -115,7 +129,7 @@ public class WorldLoader {
|
|||||||
Player player = new Player(start, gold);
|
Player player = new Player(start, gold);
|
||||||
|
|
||||||
World world = new World(rooms, items, npcs,
|
World world = new World(rooms, items, npcs,
|
||||||
gameDto.title(), gameDto.welcomeMessage(), quests, endings);
|
gameDto.title(), gameDto.welcomeMessage(), quests, endings, combinations);
|
||||||
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
log.info("World '{}' loaded: {} rooms, {} items, {} npcs",
|
||||||
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
gameDto.title(), rooms.size(), items.size(), npcs.size());
|
||||||
return new LoadResult(world, player);
|
return new LoadResult(world, player);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of an item-combination recipe. */
|
||||||
|
public record CombinationDto(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
List<ConditionDto> requires,
|
||||||
|
List<String> consume,
|
||||||
|
String produce,
|
||||||
|
List<EffectDto> effects,
|
||||||
|
String response,
|
||||||
|
String failText
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import java.util.Map;
|
|||||||
* @param exitLocks optional condition-gates per exit direction
|
* @param exitLocks optional condition-gates per exit direction
|
||||||
* @param descriptionStates optional condition-gated description variants
|
* @param descriptionStates optional condition-gated description variants
|
||||||
* @param dark whether this room is dark; nullable
|
* @param dark whether this room is dark; nullable
|
||||||
|
* @param music optional GUI background-music track filename; null = none
|
||||||
*/
|
*/
|
||||||
public record RoomDto(
|
public record RoomDto(
|
||||||
String id,
|
String id,
|
||||||
@@ -25,11 +26,12 @@ public record RoomDto(
|
|||||||
List<String> npcs,
|
List<String> npcs,
|
||||||
List<ExitLockDto> exitLocks,
|
List<ExitLockDto> exitLocks,
|
||||||
List<DescriptionStateDto> descriptionStates,
|
List<DescriptionStateDto> descriptionStates,
|
||||||
Boolean dark
|
Boolean dark,
|
||||||
|
String music
|
||||||
) {
|
) {
|
||||||
/** Backward-compatible constructor without the optional state fields. */
|
/** Backward-compatible constructor without the optional state fields. */
|
||||||
public RoomDto(String id, String name, String description,
|
public RoomDto(String id, String name, String description,
|
||||||
Map<String, String> exits, List<String> items, List<String> npcs) {
|
Map<String, String> exits, List<String> items, List<String> npcs) {
|
||||||
this(id, name, description, exits, items, npcs, null, null, null);
|
this(id, name, description, exits, items, npcs, null, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** YAML representation of the tutorial document. */
|
||||||
|
public record TutorialDto(String intro, List<TutorialStepDto> steps, String closingTips) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package thb.jeanluc.adventure.loader.dto;
|
||||||
|
|
||||||
|
/** YAML representation of a tutorial step. */
|
||||||
|
public record TutorialStepDto(
|
||||||
|
String instruction,
|
||||||
|
String expect,
|
||||||
|
Integer minArgs,
|
||||||
|
String confirm,
|
||||||
|
String hint
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -7,45 +7,41 @@ import thb.jeanluc.adventure.save.SettingsStore;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */
|
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
|
||||||
public final class SettingsMenu {
|
public final class SettingsMenu {
|
||||||
|
|
||||||
private SettingsMenu() {
|
private SettingsMenu() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Loops the settings screen until "Back", persisting + applying each change. */
|
||||||
* Loops the settings screen until the player picks "Back", persisting and
|
public static Settings show(GameIO io, Settings current, SettingsStore store) {
|
||||||
* applying each change. Returns the final settings.
|
|
||||||
*
|
|
||||||
* @param io active IO
|
|
||||||
* @param current starting settings
|
|
||||||
* @param store where to persist
|
|
||||||
* @param consoleIo the console IO to apply changes to live, or null if not console
|
|
||||||
*/
|
|
||||||
public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) {
|
|
||||||
Settings s = current;
|
Settings s = current;
|
||||||
while (true) {
|
while (true) {
|
||||||
int i = io.choose("SETTINGS", List.of(
|
int i = io.choose("SETTINGS", List.of(
|
||||||
"Colour: " + s.colorMode(),
|
"Colour: " + s.colorMode(),
|
||||||
"Glyphs: " + s.glyphMode(),
|
"Glyphs: " + s.glyphMode(),
|
||||||
|
"Music: " + s.musicLevel(),
|
||||||
"Back"));
|
"Back"));
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
s = new Settings(nextColor(s.colorMode()), s.glyphMode());
|
s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
|
||||||
} else if (i == 1) {
|
} else if (i == 1) {
|
||||||
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()));
|
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
|
||||||
|
} else if (i == 2) {
|
||||||
|
s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
|
||||||
} else {
|
} else {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
store.save(s);
|
store.save(s);
|
||||||
apply(s, consoleIo);
|
apply(s, io);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies colour/glyph settings to the console IO (no-op if null). */
|
/** Applies settings to the IO: music level always; colour/glyph only on the console. */
|
||||||
public static void apply(Settings s, ConsoleIO consoleIo) {
|
public static void apply(Settings s, GameIO io) {
|
||||||
if (consoleIo != null) {
|
io.setMusicLevel(s.musicLevel());
|
||||||
consoleIo.setColorMode(s.colorMode());
|
if (io instanceof ConsoleIO c) {
|
||||||
consoleIo.setGlyphMode(s.glyphMode());
|
c.setColorMode(s.colorMode());
|
||||||
|
c.setGlyphMode(s.glyphMode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data-driven item-combination recipe. Identified by an unordered pair of
|
||||||
|
* item ids ({@code a}, {@code b}). When applied it may require conditions,
|
||||||
|
* consume items, produce an item, apply effects, and print a response.
|
||||||
|
*/
|
||||||
|
public record Combination(
|
||||||
|
String a,
|
||||||
|
String b,
|
||||||
|
List<Condition> requires,
|
||||||
|
List<String> consume,
|
||||||
|
String produce,
|
||||||
|
List<Effect> effects,
|
||||||
|
String response,
|
||||||
|
String failText
|
||||||
|
) {
|
||||||
|
public Combination {
|
||||||
|
requires = requires == null ? List.of() : List.copyOf(requires);
|
||||||
|
consume = consume == null ? List.of() : List.copyOf(consume);
|
||||||
|
effects = effects == null ? List.of() : List.copyOf(effects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Order-independent key for an item pair (sorted, '|'-joined). */
|
||||||
|
public static String key(String x, String y) {
|
||||||
|
return x.compareTo(y) <= 0 ? x + "|" + y : y + "|" + x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This recipe's canonical pair key. */
|
||||||
|
public String key() {
|
||||||
|
return key(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ public class Room {
|
|||||||
@Setter
|
@Setter
|
||||||
private boolean dark;
|
private boolean dark;
|
||||||
|
|
||||||
|
/** Optional GUI background-music track filename for this room; null = none. */
|
||||||
|
@Setter
|
||||||
|
private String music;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects this room to another in the given direction. Does not
|
* Connects this room to another in the given direction. Does not
|
||||||
* create the reverse connection — callers must set that up
|
* create the reverse connection — callers must set that up
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Onboarding walkthrough: an intro, ordered steps, and closing tips. */
|
||||||
|
public record Tutorial(String intro, List<TutorialStep> steps, String closingTips) {
|
||||||
|
|
||||||
|
public Tutorial {
|
||||||
|
steps = steps == null ? List.of() : List.copyOf(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return steps.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An absent tutorial (no steps). */
|
||||||
|
public static Tutorial none() {
|
||||||
|
return new Tutorial(null, List.of(), null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One interactive tutorial step: an instruction, the command that satisfies it
|
||||||
|
* ({@code expect} is a verb name like "look", or "go_direction"/"go_to_room"),
|
||||||
|
* a minimum argument count, a success confirmation, and a hint for a valid but
|
||||||
|
* wrong command.
|
||||||
|
*/
|
||||||
|
public record TutorialStep(
|
||||||
|
String instruction,
|
||||||
|
String expect,
|
||||||
|
int minArgs,
|
||||||
|
String confirm,
|
||||||
|
String hint
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -37,15 +37,24 @@ public class World {
|
|||||||
/** Ordered list of endings (first matching one wins). */
|
/** Ordered list of endings (first matching one wins). */
|
||||||
private final List<Ending> endings;
|
private final List<Ending> endings;
|
||||||
|
|
||||||
/** Backward-compatible constructor for worlds without quests or endings. */
|
/** Global lookup of combination recipes by canonical pair key. */
|
||||||
|
private final Map<String, Combination> combinations;
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds without quests, endings, or combinations. */
|
||||||
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
String title, String welcomeMessage) {
|
String title, String welcomeMessage) {
|
||||||
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of());
|
this(rooms, items, npcs, title, welcomeMessage, Map.of(), List.of(), Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backward-compatible constructor for worlds with quests but no endings. */
|
/** Backward-compatible constructor for worlds with quests but no endings/combinations. */
|
||||||
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
String title, String welcomeMessage, Map<String, Quest> quests) {
|
String title, String welcomeMessage, Map<String, Quest> quests) {
|
||||||
this(rooms, items, npcs, title, welcomeMessage, quests, List.of());
|
this(rooms, items, npcs, title, welcomeMessage, quests, List.of(), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for worlds with quests + endings but no combinations. */
|
||||||
|
public World(Map<String, Room> rooms, Map<String, Item> items, Map<String, Npc> npcs,
|
||||||
|
String title, String welcomeMessage, Map<String, Quest> quests, List<Ending> endings) {
|
||||||
|
this(rooms, items, npcs, title, welcomeMessage, quests, endings, Map.of());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
package thb.jeanluc.adventure.save;
|
package thb.jeanluc.adventure.save;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
|
import thb.jeanluc.adventure.io.MusicLevel;
|
||||||
|
|
||||||
/** Persisted user preferences (minimal: colour + glyph fidelity). */
|
/** Persisted user preferences (colour + glyph fidelity + music level). */
|
||||||
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
|
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) {
|
||||||
|
|
||||||
|
public Settings {
|
||||||
|
if (colorMode == null) {
|
||||||
|
colorMode = ConsoleIO.ColorMode.AUTO;
|
||||||
|
}
|
||||||
|
if (glyphMode == null) {
|
||||||
|
glyphMode = ConsoleIO.GlyphMode.UNICODE;
|
||||||
|
}
|
||||||
|
if (musicLevel == null) {
|
||||||
|
musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backward-compatible constructor for callers that don't set music. */
|
||||||
|
public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
|
||||||
|
this(colorMode, glyphMode, MusicLevel.MEDIUM);
|
||||||
|
}
|
||||||
|
|
||||||
public static Settings defaults() {
|
public static Settings defaults() {
|
||||||
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE);
|
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- a: matches
|
||||||
|
b: torch
|
||||||
|
consume: [matches, torch]
|
||||||
|
produce: lit_torch
|
||||||
|
response: |
|
||||||
|
You strike a match and touch it to the torch; it catches with a low hiss
|
||||||
|
and burns steady. You now hold a lit torch.
|
||||||
@@ -47,3 +47,19 @@
|
|||||||
You ease the door shut again.
|
You ease the door shut again.
|
||||||
effects:
|
effects:
|
||||||
- { setFlag: fled }
|
- { setFlag: fled }
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: matches
|
||||||
|
name: Box of Matches
|
||||||
|
description: A small box of dry matches. They rattle when you shake it.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: torch
|
||||||
|
name: Unlit Torch
|
||||||
|
description: A pitch-soaked torch, waiting for a flame.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: lit_torch
|
||||||
|
name: Lit Torch
|
||||||
|
description: A burning torch, throwing back the dark.
|
||||||
|
light: true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
description: |
|
description: |
|
||||||
A dusty kitchen. Cobwebs hang from the rafters
|
A dusty kitchen. Cobwebs hang from the rafters
|
||||||
and a letter lies on the table.
|
and a letter lies on the table.
|
||||||
|
music: manor-theme.ogg
|
||||||
exits:
|
exits:
|
||||||
north: hallway
|
north: hallway
|
||||||
east: cellar
|
east: cellar
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
exits:
|
exits:
|
||||||
south: kitchen
|
south: kitchen
|
||||||
west: library
|
west: library
|
||||||
items: []
|
items: [torch]
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|
||||||
- id: library
|
- id: library
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
An old chest stands in the corner.
|
An old chest stands in the corner.
|
||||||
exits:
|
exits:
|
||||||
east: hallway
|
east: hallway
|
||||||
items: [shovel]
|
items: [shovel, matches]
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|
||||||
- id: cellar
|
- id: cellar
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
name: Dungeon
|
name: Dungeon
|
||||||
description: |
|
description: |
|
||||||
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
||||||
|
music: dungeon-drone.ogg
|
||||||
dark: true
|
dark: true
|
||||||
exits:
|
exits:
|
||||||
north: kitchen
|
north: kitchen
|
||||||
|
|||||||
40
Semesterprojekt/src/main/resources/world/tutorial.yaml
Normal file
40
Semesterprojekt/src/main/resources/world/tutorial.yaml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
intro: |
|
||||||
|
Welcome to the manor. Let me show you the ropes — just type the commands as
|
||||||
|
I describe them. (Type 'skip' at any time to jump straight into the game.)
|
||||||
|
steps:
|
||||||
|
- instruction: "First, look around the room: type 'look'."
|
||||||
|
expect: look
|
||||||
|
confirm: "Good — 'look' (or 'l') re-describes wherever you are."
|
||||||
|
hint: "When you're ready, just type: look"
|
||||||
|
- instruction: "Examine something you noticed: 'examine <thing>' (or 'x <thing>')."
|
||||||
|
expect: examine
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "That's how you inspect items, exits, and people up close."
|
||||||
|
hint: "Try examining something, e.g.: examine lamp"
|
||||||
|
- instruction: "Pick something up: 'take <item>'."
|
||||||
|
expect: take
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Taken. Things you carry go into your inventory."
|
||||||
|
hint: "Pick something up, e.g.: take lamp"
|
||||||
|
- instruction: "Check what you're carrying: 'inventory' (or 'i')."
|
||||||
|
expect: inventory
|
||||||
|
confirm: "That's your inventory."
|
||||||
|
hint: "Type: inventory"
|
||||||
|
- instruction: "Use an item: 'use <item>' — try lighting the lamp."
|
||||||
|
expect: use
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Some items toggle, read, or react when you use them."
|
||||||
|
hint: "Use something, e.g.: use lamp"
|
||||||
|
- instruction: "Now move to another room: 'go <direction>' (try 'go north')."
|
||||||
|
expect: go_direction
|
||||||
|
confirm: "You can move north / south / east / west between connected rooms."
|
||||||
|
hint: "Head to a connected room, e.g.: go north"
|
||||||
|
- instruction: "Travel back to a room you've already seen: 'go to <room>'."
|
||||||
|
expect: go_to_room
|
||||||
|
confirm: "Nice — 'go to <room>' auto-walks to any room you've already visited."
|
||||||
|
hint: "Try: go to <a room you've already visited>"
|
||||||
|
closingTips: |
|
||||||
|
That's the core. A few more you'll want:
|
||||||
|
read · drop · talk to <npc> · give <item> to <npc> ·
|
||||||
|
use <item> on <item> (combine things) · map · quests · save · menu.
|
||||||
|
Type 'help' anytime. Good luck.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GoCommandPathTest {
|
||||||
|
|
||||||
|
/** kitchen --N--> hallway --N--> library (all lit). Optional lock on kitchen->hallway. */
|
||||||
|
private GameContext world(boolean lockHallway, String libraryName) {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
Room library = new Room("library", libraryName, "d");
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
hallway.addExit(Direction.SOUTH, kitchen);
|
||||||
|
hallway.addExit(Direction.NORTH, library);
|
||||||
|
library.addExit(Direction.SOUTH, hallway);
|
||||||
|
if (lockHallway) {
|
||||||
|
kitchen.addExitLock(Direction.NORTH,
|
||||||
|
new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "It's locked."));
|
||||||
|
}
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
rooms.put("library", library);
|
||||||
|
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
Player p = new Player(kitchen, 0);
|
||||||
|
p.getVisitedRoomIds().addAll(List.of("kitchen", "hallway", "library"));
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String out(GameContext ctx) {
|
||||||
|
return ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goToWalksToVisitedRoomById() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("library"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library");
|
||||||
|
assertThat(out(ctx)).contains("make your way");
|
||||||
|
assertThat(out(ctx)).contains("through the Hallway");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesByRoomName() {
|
||||||
|
// id "library" but type the display name (lower-cased) — still resolves
|
||||||
|
GameContext ctx = world(false, "Grand Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("grand", "library"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("library");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownPlaceReportsSo() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("dungeon"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||||
|
assertThat(out(ctx)).contains("don't know any direction or place called 'dungeon'");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alreadyThere() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("kitchen"));
|
||||||
|
assertThat(out(ctx)).contains("already in the Kitchen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noPassablePathReportsSo() {
|
||||||
|
GameContext ctx = world(true, "Library"); // kitchen->hallway locked, flag unset
|
||||||
|
new GoCommand().execute(ctx, List.of("library"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||||
|
assertThat(out(ctx)).contains("can't find a way there");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void directionalMoveStillWorks() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("north"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("hallway");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adjacentWalkUsesShortSummary() {
|
||||||
|
GameContext ctx = world(false, "Library");
|
||||||
|
new GoCommand().execute(ctx, List.of("hallway"));
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().getId()).isEqualTo("hallway");
|
||||||
|
assertThat(out(ctx)).contains("make your way from the Kitchen to the Hallway");
|
||||||
|
assertThat(out(ctx)).doesNotContain("through");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
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 thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
import thb.jeanluc.adventure.model.item.SwitchableItem;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class UseCommandComboTest {
|
||||||
|
|
||||||
|
private GameContext ctx() {
|
||||||
|
Map<String, Item> items = new LinkedHashMap<>();
|
||||||
|
items.put("matches", PlainItem.builder().id("matches").name("Matches").description("d").light(false).build());
|
||||||
|
items.put("torch", PlainItem.builder().id("torch").name("Torch").description("d").light(false).build());
|
||||||
|
items.put("lit_torch", PlainItem.builder().id("lit_torch").name("Lit Torch").description("d").light(true).build());
|
||||||
|
SwitchableItem lamp = SwitchableItem.builder()
|
||||||
|
.id("lamp").name("Lamp").description("d").light(true)
|
||||||
|
.onText("The lamp glows.").offText("off").state(false).effects(List.of()).build();
|
||||||
|
items.put("lamp", lamp);
|
||||||
|
Combination recipe = new Combination("matches", "torch", List.of(),
|
||||||
|
List.of("matches", "torch"), "lit_torch", List.of(), "You light the torch.", null);
|
||||||
|
Room start = new Room("start", "Start", "d");
|
||||||
|
World w = new World(Map.of("start", start), items, Map.of(), "t", "w",
|
||||||
|
Map.of(), List.of(), Map.of(recipe.key(), recipe));
|
||||||
|
GameContext ctx = new GameContext(w, new Player(start, 0), new TestIO());
|
||||||
|
ctx.getPlayer().addItem(items.get("matches"));
|
||||||
|
ctx.getPlayer().addItem(items.get("torch"));
|
||||||
|
ctx.getPlayer().addItem(lamp);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyArgsAsksWhat() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
new UseCommand().execute(ctx, List.of());
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("Use what?");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoArgsRoutesToCombination() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
new UseCommand().execute(ctx, List.of("matches", "torch"));
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("light the torch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneArgUsesItemDirectly() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
new UseCommand().execute(ctx, List.of("lamp"));
|
||||||
|
assertThat(((SwitchableItem) ctx.getWorld().getItems().get("lamp")).isOn()).isTrue();
|
||||||
|
assertThat(((TestIO) ctx.getIo()).allOutput()).contains("lamp glows");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Effect;
|
||||||
|
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 thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CombinationsTest {
|
||||||
|
|
||||||
|
private Item plain(String id, boolean light) {
|
||||||
|
return PlainItem.builder().id(id).name(id).description("d").light(light).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** World with matches/torch/lit_torch/shovel and the given recipes; player in an empty room. */
|
||||||
|
private GameContext ctx(Combination... recipes) {
|
||||||
|
Map<String, Item> items = new LinkedHashMap<>();
|
||||||
|
items.put("matches", plain("matches", false));
|
||||||
|
items.put("torch", plain("torch", false));
|
||||||
|
items.put("lit_torch", plain("lit_torch", true));
|
||||||
|
items.put("shovel", plain("shovel", false));
|
||||||
|
Map<String, Combination> combos = new HashMap<>();
|
||||||
|
for (Combination c : recipes) {
|
||||||
|
combos.put(c.key(), c);
|
||||||
|
}
|
||||||
|
Room start = new Room("start", "Start", "d");
|
||||||
|
World w = new World(Map.of("start", start), items, Map.of(), "t", "w",
|
||||||
|
Map.of(), List.of(), combos);
|
||||||
|
return new GameContext(w, new Player(start, 0), new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void give(GameContext ctx, String id) {
|
||||||
|
ctx.getPlayer().addItem(ctx.getWorld().getItems().get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void putInRoom(GameContext ctx, String id) {
|
||||||
|
ctx.getPlayer().getCurrentRoom().addItem(ctx.getWorld().getItems().get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String out(GameContext ctx) {
|
||||||
|
return ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Combination torchRecipe() {
|
||||||
|
return new Combination("matches", "torch", List.of(),
|
||||||
|
List.of("matches", "torch"), "lit_torch", List.of(),
|
||||||
|
"You light the torch.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void combinesFromInventory() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(ctx.getPlayer().hasItem("matches")).isFalse();
|
||||||
|
assertThat(ctx.getPlayer().hasItem("torch")).isFalse();
|
||||||
|
assertThat(out(ctx)).contains("light the torch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void matchIsOrderIndependent() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "torch", "matches");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumesOperandFromCurrentRoom() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches");
|
||||||
|
putInRoom(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(ctx.getPlayer().getCurrentRoom().findItem("torch")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingOperandReportsNoSuch() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "matches"); // torch absent
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(out(ctx)).contains("no 'torch'");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noRecipeForPairSaysNothingHappens() {
|
||||||
|
GameContext ctx = ctx(torchRecipe());
|
||||||
|
give(ctx, "shovel");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "shovel", "torch");
|
||||||
|
assertThat(out(ctx)).contains("Nothing happens.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unmetRequiresShowsFailTextAndDoesNotProduce() {
|
||||||
|
Combination gated = new Combination("matches", "torch",
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "dry")),
|
||||||
|
List.of("matches", "torch"), "lit_torch", List.of(),
|
||||||
|
"It catches.", "The torch is too damp to light.");
|
||||||
|
GameContext ctx = ctx(gated);
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(out(ctx)).contains("too damp");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isFalse();
|
||||||
|
assertThat(ctx.getPlayer().hasItem("matches")).isTrue(); // not consumed
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void effectsOnlyRecipeAppliesEffectsWithoutProduceOrResponse() {
|
||||||
|
Combination effectsOnly = new Combination("matches", "shovel",
|
||||||
|
java.util.List.of(), java.util.List.of(), null,
|
||||||
|
java.util.List.of(new Effect(Effect.Type.SET_FLAG, "rite_done")),
|
||||||
|
null, null);
|
||||||
|
GameContext ctx = ctx(effectsOnly);
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "shovel");
|
||||||
|
Combinations.tryUse(ctx, "matches", "shovel");
|
||||||
|
assertThat(ctx.getState().isSet("rite_done")).isTrue();
|
||||||
|
assertThat(out(ctx)).doesNotContain("null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void metRequiresAppliesEffectsAndProduces() {
|
||||||
|
Combination gated = new Combination("matches", "torch",
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "dry")),
|
||||||
|
List.of("matches", "torch"), "lit_torch",
|
||||||
|
List.of(new Effect(Effect.Type.SET_FLAG, "torch_lit")),
|
||||||
|
"It catches.", "too damp");
|
||||||
|
GameContext ctx = ctx(gated);
|
||||||
|
ctx.getState().set("dry");
|
||||||
|
give(ctx, "matches");
|
||||||
|
give(ctx, "torch");
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
assertThat(ctx.getState().isSet("torch_lit")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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.LookCommand;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Ending;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class GameTutorialTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void guideBeginsAndAdvancesThenSkipEndsViaLoop() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(), "t", "w");
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
// scripted player input: a matching 'look', then 'skip'; then EOF (null) ends run()
|
||||||
|
io.enqueue("look").enqueue("skip");
|
||||||
|
GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io);
|
||||||
|
|
||||||
|
CommandRegistry registry = new CommandRegistry();
|
||||||
|
registry.register(new LookCommand(), "look", "l");
|
||||||
|
|
||||||
|
Tutorial t = new Tutorial("INTRO",
|
||||||
|
List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint"),
|
||||||
|
new TutorialStep("do look again", "look", 0, "STEP2-OK", "hint")),
|
||||||
|
"TIPS");
|
||||||
|
Game game = new Game(ctx, registry, new CommandParser());
|
||||||
|
game.setTutorialGuide(new TutorialGuide(t, registry));
|
||||||
|
|
||||||
|
game.run();
|
||||||
|
|
||||||
|
String out = io.allOutput();
|
||||||
|
assertThat(out).contains("INTRO"); // begin printed
|
||||||
|
assertThat(out).contains("STEP1-OK"); // first 'look' advanced step 1
|
||||||
|
assertThat(out).contains("Tutorial skipped"); // 'skip' ended it before step 2 finished
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void endingsAreSuppressedWhileTutorialActiveAndFireAfter() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Ending fled = new Ending("fled", "Fled", false,
|
||||||
|
List.of(new Condition(Condition.Type.FLAG, "fled")), "ENDING-TEXT");
|
||||||
|
World w = new World(Map.of("kitchen", kitchen), Map.of(), Map.of(),
|
||||||
|
"t", "w", Map.of(), List.of(fled));
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
io.enqueue("look"); // completes the 1-step tutorial; then EOF ends run()
|
||||||
|
GameContext ctx = new GameContext(new GameSession(w, new Player(kitchen, 0), "s"), io);
|
||||||
|
ctx.getState().set("fled"); // ending condition already met from the start
|
||||||
|
|
||||||
|
CommandRegistry registry = new CommandRegistry();
|
||||||
|
registry.register(new LookCommand(), "look", "l");
|
||||||
|
|
||||||
|
Tutorial t = new Tutorial("INTRO",
|
||||||
|
List.of(new TutorialStep("do look", "look", 0, "STEP1-OK", "hint")), "TIPS");
|
||||||
|
Game game = new Game(ctx, registry, new CommandParser());
|
||||||
|
game.setTutorialGuide(new TutorialGuide(t, registry));
|
||||||
|
|
||||||
|
game.run();
|
||||||
|
|
||||||
|
String out = io.allOutput();
|
||||||
|
// The 'look' was processed -> the pre-loop ending did NOT stop the game first.
|
||||||
|
assertThat(out).contains("STEP1-OK");
|
||||||
|
// After the tutorial finished, the ending fired.
|
||||||
|
assertThat(out).contains("ENDING-TEXT");
|
||||||
|
assertThat(out.indexOf("STEP1-OK")).isLessThan(out.indexOf("ENDING-TEXT"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package thb.jeanluc.adventure.game;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Condition;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.ExitLock;
|
||||||
|
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 thb.jeanluc.adventure.model.item.PlainItem;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class PathfinderTest {
|
||||||
|
|
||||||
|
private Room room(String id) {
|
||||||
|
return new Room(id, id, "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
private GameContext ctx(Room start, List<Room> all, Set<String> visited) {
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
for (Room r : all) {
|
||||||
|
rooms.put(r.getId(), r);
|
||||||
|
}
|
||||||
|
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
Player p = new Player(start, 0);
|
||||||
|
p.getVisitedRoomIds().addAll(visited);
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findsShortestPathOverVisitedRooms() {
|
||||||
|
Room a = room("a"), b = room("b"), c = room("c");
|
||||||
|
a.addExit(Direction.NORTH, b);
|
||||||
|
b.addExit(Direction.NORTH, c);
|
||||||
|
GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "b", "c"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, c)).containsExactly(b, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void willNotRouteThroughUnvisitedRoom() {
|
||||||
|
Room a = room("a"), b = room("b"), c = room("c");
|
||||||
|
a.addExit(Direction.NORTH, b);
|
||||||
|
b.addExit(Direction.NORTH, c);
|
||||||
|
GameContext ctx = ctx(a, List.of(a, b, c), Set.of("a", "c")); // b not visited
|
||||||
|
assertThat(Pathfinder.findPath(ctx, c)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void respectsLockedExit() {
|
||||||
|
Room a = room("a"), b = room("b");
|
||||||
|
a.addExit(Direction.NORTH, b);
|
||||||
|
a.addExitLock(Direction.NORTH,
|
||||||
|
new ExitLock(List.of(new Condition(Condition.Type.FLAG, "open")), "locked"));
|
||||||
|
GameContext ctx = ctx(a, List.of(a, b), Set.of("a", "b"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, b)).isEmpty();
|
||||||
|
ctx.getState().set("open");
|
||||||
|
assertThat(Pathfinder.findPath(ctx, b)).containsExactly(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void respectsDarkness() {
|
||||||
|
Room a = room("a");
|
||||||
|
Room dark = room("dark");
|
||||||
|
dark.setDark(true);
|
||||||
|
a.addExit(Direction.NORTH, dark);
|
||||||
|
GameContext ctx = ctx(a, List.of(a, dark), Set.of("a", "dark"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, dark)).isEmpty();
|
||||||
|
Item torch = PlainItem.builder().id("lit").name("Lit").description("d").light(true).build();
|
||||||
|
ctx.getPlayer().addItem(torch);
|
||||||
|
assertThat(Pathfinder.findPath(ctx, dark)).containsExactly(dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unreachableTargetReturnsEmpty() {
|
||||||
|
Room a = room("a");
|
||||||
|
Room island = room("island"); // no connecting exits
|
||||||
|
GameContext ctx = ctx(a, List.of(a, island), Set.of("a", "island"));
|
||||||
|
assertThat(Pathfinder.findPath(ctx, island)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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.ParsedCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.ExamineCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.GoCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.UseCommand;
|
||||||
|
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 thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
import thb.jeanluc.adventure.model.TutorialStep;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TutorialGuideTest {
|
||||||
|
|
||||||
|
private CommandRegistry registry() {
|
||||||
|
CommandRegistry r = new CommandRegistry();
|
||||||
|
r.register(new LookCommand(), "look", "l");
|
||||||
|
r.register(new ExamineCommand(), "examine", "x");
|
||||||
|
r.register(new TakeCommand(), "take", "get");
|
||||||
|
r.register(new InventoryCommand(), "inventory", "i");
|
||||||
|
r.register(new UseCommand(), "use");
|
||||||
|
r.register(new GoCommand(), "go", "move", "walk");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
private final Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
|
||||||
|
private GameContext ctx() {
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
World w = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
Player p = new Player(kitchen, 0);
|
||||||
|
return new GameContext(w, p, new TestIO());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParsedCommand parse(String s) {
|
||||||
|
return new CommandParser().parse(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String out(GameContext ctx) {
|
||||||
|
return ((TestIO) ctx.getIo()).allOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private TutorialStep step(String instr, String expect, int minArgs) {
|
||||||
|
return new TutorialStep(instr, expect, minArgs, "OK:" + expect, "HINT:" + expect);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void beginPrintsIntroAndFirstInstruction() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
assertThat(out(ctx)).contains("INTRO").contains("do look");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aliasSatisfiesVerbStepAndAdvancesToTips() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("INTRO", List.of(step("do look", "look", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.onCommand(ctx, parse("l")); // alias of look
|
||||||
|
assertThat(out(ctx)).contains("OK:look").contains("TIPS");
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void minArgsGatesTakeStep() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("take it", "take", 1)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.onCommand(ctx, parse("take")); // no arg -> hint, not satisfied
|
||||||
|
assertThat(out(ctx)).contains("HINT:take");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
g.onCommand(ctx, parse("take lamp")); // satisfied
|
||||||
|
assertThat(out(ctx)).contains("OK:take");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goDirectionRequiresAnActualMove() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("move", "go_direction", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
// typed a direction but did NOT move -> hint
|
||||||
|
g.onCommand(ctx, parse("go north"));
|
||||||
|
assertThat(out(ctx)).contains("HINT:go_direction");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
// now actually move, then the same command satisfies
|
||||||
|
ctx.getPlayer().setCurrentRoom(hallway);
|
||||||
|
g.onCommand(ctx, parse("go north"));
|
||||||
|
assertThat(out(ctx)).contains("OK:go_direction");
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goToRoomNeedsNonDirectionArgAndMove() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
ctx.getPlayer().setCurrentRoom(hallway); // moved
|
||||||
|
g.onCommand(ctx, parse("go kitchen")); // "kitchen" is not a direction
|
||||||
|
assertThat(out(ctx)).contains("OK:go_to_room");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void skipDeactivates() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("do look", "look", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.skip(ctx);
|
||||||
|
assertThat(out(ctx)).contains("Tutorial skipped");
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyTutorialIsInactive() {
|
||||||
|
TutorialGuide g = new TutorialGuide(Tutorial.none(), registry());
|
||||||
|
assertThat(g.isActive()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void advancingPrintsTheNextInstruction() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I",
|
||||||
|
List.of(step("do look", "look", 0), step("do take", "take", 1)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.onCommand(ctx, parse("look"));
|
||||||
|
assertThat(out(ctx)).contains("OK:look").contains("do take");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void goToRoomWithoutMovingGivesHint() {
|
||||||
|
GameContext ctx = ctx();
|
||||||
|
Tutorial t = new Tutorial("I", List.of(step("travel", "go_to_room", 0)), "TIPS");
|
||||||
|
TutorialGuide g = new TutorialGuide(t, registry());
|
||||||
|
g.begin(ctx);
|
||||||
|
g.onCommand(ctx, parse("go kitchen")); // non-direction arg but no room change
|
||||||
|
assertThat(out(ctx)).contains("HINT:go_to_room");
|
||||||
|
assertThat(g.isActive()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MusicControllerTest {
|
||||||
|
|
||||||
|
/** Records backend calls for assertions. */
|
||||||
|
private static final class FakeBackend implements MusicBackend {
|
||||||
|
final List<String> calls = new ArrayList<>();
|
||||||
|
float lastGain = Float.NaN;
|
||||||
|
public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); lastGain = gainDb; }
|
||||||
|
public void fadeOut() { calls.add("fadeOut"); }
|
||||||
|
public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); }
|
||||||
|
public void shutdown() { calls.add("shutdown"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstRoomWithTrackPlaysAtDefaultLevel() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
new MusicController(b).room("a");
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameTrackDoesNotRestart() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
b.calls.clear();
|
||||||
|
c.room("a");
|
||||||
|
assertThat(b.calls).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void differentTrackSwitches() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.room("b");
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullOrBlankRoomFadesToSilence() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.room(null);
|
||||||
|
c.room(" ");
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void offStopsAndIgnoresRooms() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.level(MusicLevel.OFF);
|
||||||
|
b.calls.clear();
|
||||||
|
c.room("b");
|
||||||
|
assertThat(b.calls).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void levelChangeSetsGain() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b); // default MEDIUM
|
||||||
|
c.level(MusicLevel.HIGH);
|
||||||
|
assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void turningOffThenOnReplaysOnNextRoom() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
c.level(MusicLevel.OFF); // fadeOut
|
||||||
|
c.level(MusicLevel.MEDIUM); // setGain
|
||||||
|
b.calls.clear();
|
||||||
|
c.room("a"); // current was reset → plays again
|
||||||
|
assertThat(b.calls).containsExactly("fadeTo:a");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forwardsTheCurrentLevelGain() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
MusicController c = new MusicController(b);
|
||||||
|
c.room("a");
|
||||||
|
assertThat(b.lastGain).isEqualTo(MusicLevel.MEDIUM.gainDb());
|
||||||
|
c.level(MusicLevel.HIGH);
|
||||||
|
c.room("b");
|
||||||
|
assertThat(b.lastGain).isEqualTo(MusicLevel.HIGH.gainDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shutdownDelegatesToBackend() {
|
||||||
|
FakeBackend b = new FakeBackend();
|
||||||
|
new MusicController(b).shutdown();
|
||||||
|
assertThat(b.calls).containsExactly("shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MusicLevelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gainIncreasesWithLevel() {
|
||||||
|
assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb());
|
||||||
|
assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isOnOnlyWhenNotOff() {
|
||||||
|
assertThat(MusicLevel.OFF.isOn()).isFalse();
|
||||||
|
assertThat(MusicLevel.LOW.isOn()).isTrue();
|
||||||
|
assertThat(MusicLevel.MEDIUM.isOn()).isTrue();
|
||||||
|
assertThat(MusicLevel.HIGH.isOn()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nextCyclesThroughAllAndWraps() {
|
||||||
|
assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW);
|
||||||
|
assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM);
|
||||||
|
assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH);
|
||||||
|
assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MusicSeamDefaultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultMusicSeamsAreSilentNoOps() {
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
io.setMusic("anything.ogg");
|
||||||
|
io.setMusic(null);
|
||||||
|
io.setMusicLevel(MusicLevel.HIGH);
|
||||||
|
assertThat(io.outputs()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class TravelStepDefaultTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultTravelStepIsSilentNoOp() {
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
// An empty map view is fine; the default must not print or throw.
|
||||||
|
io.travelStep(new MapView(List.of(), List.of()));
|
||||||
|
assertThat(io.outputs()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.CombinationDto;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
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 CombinationFactoryTest {
|
||||||
|
|
||||||
|
private Map<String, Item> items() {
|
||||||
|
Map<String, Item> m = new HashMap<>();
|
||||||
|
for (String id : List.of("matches", "torch", "lit_torch")) {
|
||||||
|
m.put(id, PlainItem.builder().id(id).name(id).description("d").light(false).build());
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsCombinationFromDto() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "torch", null,
|
||||||
|
List.of("matches", "torch"), "lit_torch", null, "You light it.", null);
|
||||||
|
Combination c = CombinationFactory.fromDto(dto, items());
|
||||||
|
assertThat(c.key()).isEqualTo("matches|torch");
|
||||||
|
assertThat(c.consume()).containsExactly("matches", "torch");
|
||||||
|
assertThat(c.produce()).isEqualTo("lit_torch");
|
||||||
|
assertThat(c.response()).isEqualTo("You light it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownOperandId() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "ghost", null, null, null, null, "x", null);
|
||||||
|
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
|
||||||
|
.isInstanceOf(WorldLoadException.class)
|
||||||
|
.hasMessageContaining("ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownProduceId() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "torch", null, null, "phantom", null, "x", null);
|
||||||
|
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
|
||||||
|
.isInstanceOf(WorldLoadException.class)
|
||||||
|
.hasMessageContaining("phantom");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownConsumeId() {
|
||||||
|
CombinationDto dto = new CombinationDto("matches", "torch", null,
|
||||||
|
List.of("matches", "wraith"), null, null, "x", null);
|
||||||
|
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
|
||||||
|
.isInstanceOf(WorldLoadException.class)
|
||||||
|
.hasMessageContaining("wraith");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsSelfCombination() {
|
||||||
|
CombinationDto dto = new CombinationDto("torch", "torch", null, null, null, null, "x", null);
|
||||||
|
assertThatThrownBy(() -> CombinationFactory.fromDto(dto, items()))
|
||||||
|
.isInstanceOf(WorldLoadException.class)
|
||||||
|
.hasMessageContaining("itself");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.Combinations;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.game.GameSession;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Combination;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CombinationLoadingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void realWorldLoadsTheTorchRecipe() {
|
||||||
|
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||||
|
assertThat(loaded.world().getCombinations())
|
||||||
|
.containsKey(Combination.key("matches", "torch"));
|
||||||
|
assertThat(loaded.world().getItems()).containsKeys("matches", "torch", "lit_torch");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void torchRecipeWorksEndToEnd() {
|
||||||
|
WorldLoader.LoadResult loaded = new WorldLoader().load();
|
||||||
|
GameContext ctx = new GameContext(
|
||||||
|
new GameSession(loaded.world(), loaded.player(), "test"), new TestIO());
|
||||||
|
// grab the demo items straight from the registry, regardless of room
|
||||||
|
ctx.getPlayer().addItem(ctx.getWorld().getItems().get("matches"));
|
||||||
|
ctx.getPlayer().addItem(ctx.getWorld().getItems().get("torch"));
|
||||||
|
Combinations.tryUse(ctx, "matches", "torch");
|
||||||
|
assertThat(ctx.getPlayer().hasItem("lit_torch")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.loader.dto.RoomDto;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class RoomMusicTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void musicFieldIsCarriedOntoRoom() {
|
||||||
|
RoomDto dto = new RoomDto("r", "Room", "d",
|
||||||
|
Map.of(), List.of(), List.of(), null, null, null, "theme.ogg");
|
||||||
|
Room room = RoomFactory.shellFromDto(dto);
|
||||||
|
assertThat(room.getMusic()).isEqualTo("theme.ogg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void absentMusicIsNull() {
|
||||||
|
RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of());
|
||||||
|
Room room = RoomFactory.shellFromDto(dto);
|
||||||
|
assertThat(room.getMusic()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package thb.jeanluc.adventure.loader;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.model.Tutorial;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class TutorialLoaderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadsTheTestFixture() {
|
||||||
|
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
|
||||||
|
assertThat(t.isEmpty()).isFalse();
|
||||||
|
assertThat(t.intro()).isNotBlank();
|
||||||
|
assertThat(t.closingTips()).isNotBlank();
|
||||||
|
assertThat(t.steps()).hasSize(2);
|
||||||
|
assertThat(t.steps().getFirst().expect()).isEqualTo("look");
|
||||||
|
assertThat(t.steps().getFirst().minArgs()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingFileReturnsEmptyTutorial() {
|
||||||
|
Tutorial t = new TutorialLoader("/no-such-base").load();
|
||||||
|
assertThat(t.isEmpty()).isTrue();
|
||||||
|
assertThat(t.steps()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedFileThrowsWorldLoadException() {
|
||||||
|
// /badtut/tutorial.yaml has 'steps: 12345' (scalar where a list is required)
|
||||||
|
assertThatThrownBy(() -> new TutorialLoader("/badtut").load())
|
||||||
|
.isInstanceOf(WorldLoadException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,8 @@ class WorldLoaderTest {
|
|||||||
|
|
||||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
||||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
||||||
assertThat(result.world().getItems().keySet()).containsExactlyInAnyOrder("letter", "lamp", "key");
|
assertThat(result.world().getItems().keySet())
|
||||||
|
.containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch");
|
||||||
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
||||||
|
|
||||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import thb.jeanluc.adventure.save.SaveSlotInfo;
|
|||||||
import thb.jeanluc.adventure.save.Settings;
|
import thb.jeanluc.adventure.save.Settings;
|
||||||
import thb.jeanluc.adventure.save.SettingsStore;
|
import thb.jeanluc.adventure.save.SettingsStore;
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
|
import thb.jeanluc.adventure.io.MusicLevel;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
@@ -64,11 +65,18 @@ class MenuTest {
|
|||||||
@Test
|
@Test
|
||||||
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
|
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
|
||||||
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||||
// choose "Glyph mode" (assume option 2) → its toggle, then "Back" (last)
|
TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back
|
||||||
TestIO io = new TestIO().enqueue("2").enqueue("3");
|
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
|
||||||
Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO());
|
|
||||||
// UNICODE default toggles to ASCII; persisted
|
|
||||||
assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||||
assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) {
|
||||||
|
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||||
|
TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back
|
||||||
|
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
|
||||||
|
assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH
|
||||||
|
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class CombinationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keyIsOrderIndependentAndSorted() {
|
||||||
|
assertThat(Combination.key("matches", "torch"))
|
||||||
|
.isEqualTo(Combination.key("torch", "matches"))
|
||||||
|
.isEqualTo("matches|torch");
|
||||||
|
assertThat(Combination.key("b", "a")).isEqualTo("a|b");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullCollectionComponentsBecomeEmpty() {
|
||||||
|
Combination c = new Combination("a", "b", null, null, null, null, "ok", null);
|
||||||
|
assertThat(c.requires()).isEmpty();
|
||||||
|
assertThat(c.consume()).isEmpty();
|
||||||
|
assertThat(c.effects()).isEmpty();
|
||||||
|
assertThat(c.produce()).isNull();
|
||||||
|
assertThat(c.key()).isEqualTo("a|b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package thb.jeanluc.adventure.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class WorldCombinationsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legacyConstructorsDefaultToEmptyCombinations() {
|
||||||
|
World w5 = new World(Map.of(), Map.of(), Map.of(), "t", "w");
|
||||||
|
World w6 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of());
|
||||||
|
World w7 = new World(Map.of(), Map.of(), Map.of(), "t", "w", Map.of(), List.of());
|
||||||
|
assertThat(w5.getCombinations()).isEmpty();
|
||||||
|
assertThat(w6.getCombinations()).isEmpty();
|
||||||
|
assertThat(w7.getCombinations()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fullConstructorStoresCombinations() {
|
||||||
|
Combination c = new Combination("a", "b", List.of(), List.of(), null, List.of(), "r", null);
|
||||||
|
World w = new World(Map.of(), Map.of(), Map.of(), "t", "w",
|
||||||
|
Map.of(), List.of(), Map.of(c.key(), c));
|
||||||
|
assertThat(w.getCombinations()).containsKey("a|b");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.save;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import thb.jeanluc.adventure.io.ConsoleIO;
|
import thb.jeanluc.adventure.io.ConsoleIO;
|
||||||
|
import thb.jeanluc.adventure.io.MusicLevel;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -19,6 +20,7 @@ class SettingsStoreTest {
|
|||||||
Settings loaded = store.load();
|
Settings loaded = store.load();
|
||||||
assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON);
|
assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON);
|
||||||
assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
|
||||||
|
assertThat(loaded.musicLevel()).isEqualTo(MusicLevel.MEDIUM);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -34,4 +36,18 @@ class SettingsStoreTest {
|
|||||||
Settings loaded = new SettingsStore(file).load();
|
Settings loaded = new SettingsStore(file).load();
|
||||||
assertThat(loaded).isEqualTo(Settings.defaults());
|
assertThat(loaded).isEqualTo(Settings.defaults());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void musicLevelRoundTrips(@TempDir Path dir) {
|
||||||
|
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
|
||||||
|
store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH));
|
||||||
|
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception {
|
||||||
|
Path file = dir.resolve("settings.json");
|
||||||
|
Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}");
|
||||||
|
assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
Semesterprojekt/src/test/resources/badtut/tutorial.yaml
Normal file
5
Semesterprojekt/src/test/resources/badtut/tutorial.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Malformed on purpose: 'steps' must be a list, not a scalar.
|
||||||
|
# Used by TutorialLoaderTest to verify parse errors become WorldLoadException.
|
||||||
|
intro: "bad"
|
||||||
|
steps: 12345
|
||||||
|
closingTips: "bad"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- a: matches
|
||||||
|
b: torch
|
||||||
|
consume: [matches, torch]
|
||||||
|
produce: lit_torch
|
||||||
|
response: |
|
||||||
|
You strike a match and touch it to the torch; it catches with a low hiss
|
||||||
|
and burns steady. You now hold a lit torch.
|
||||||
@@ -17,3 +17,19 @@
|
|||||||
id: key
|
id: key
|
||||||
name: Key
|
name: Key
|
||||||
description: A key.
|
description: A key.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: matches
|
||||||
|
name: Box of Matches
|
||||||
|
description: A small box of dry matches.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: torch
|
||||||
|
name: Unlit Torch
|
||||||
|
description: A pitch-soaked torch.
|
||||||
|
|
||||||
|
- type: plain
|
||||||
|
id: lit_torch
|
||||||
|
name: Lit Torch
|
||||||
|
description: A burning torch.
|
||||||
|
light: true
|
||||||
|
|||||||
14
Semesterprojekt/src/test/resources/world/tutorial.yaml
Normal file
14
Semesterprojekt/src/test/resources/world/tutorial.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
intro: |
|
||||||
|
Test intro.
|
||||||
|
steps:
|
||||||
|
- instruction: "Look: type 'look'."
|
||||||
|
expect: look
|
||||||
|
confirm: "Looked."
|
||||||
|
hint: "Type look."
|
||||||
|
- instruction: "Take: type 'take <item>'."
|
||||||
|
expect: take
|
||||||
|
minArgs: 1
|
||||||
|
confirm: "Took it."
|
||||||
|
hint: "Take something."
|
||||||
|
closingTips: |
|
||||||
|
Test tips.
|
||||||
Reference in New Issue
Block a user