Compare commits

...

51 Commits

Author SHA1 Message Date
dacac81382 Merge GUI background music into develop
Per-room OGG Vorbis background music in the Swing GUI: a testable MusicController
(idempotent track switch, null->silence, OFF disables) over an isolated
OggMusicBackend (vorbisspi/jorbis, streamed PCM, ~400ms gain fades, graceful on
missing files). Driven from GameIO.setMusic per turn (console no-op) and a new
persisted Music level (Off/Low/Med/High, default Medium) in Settings. Tracks come
from an external gitignored music/ folder via an optional rooms.yaml `music:`
field. Console stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:29:11 +02:00
b5d603a7b1 docs: clarify music transition is a sequential fade, not cross-fade 2026-06-01 23:29:11 +02:00
08c92a57de docs: mark GUI background music implemented 2026-06-01 23:25:11 +02:00
2891553629 feat(io): OggMusicBackend + SwingIO wiring; music/ folder + docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:20:14 +02:00
bcd2ae9ddd refactor(settings): guard all Settings fields vs null; tidy import + test
Guard colorMode and glyphMode against null in the Settings compact
constructor (alongside the existing musicLevel guard), so a hand-edited
settings.json with a missing field no longer NPEs in SettingsMenu.
Remove the unused MusicLevel import from SettingsMenu. Assert that the
defaulted musicLevel survives a round-trip in SettingsStoreTest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:17:26 +02:00
2bad875df1 feat(settings): persisted Music level; SettingsMenu applies via GameIO
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 23:08:27 +02:00
d0d8648bd0 feat(io): GameIO music seams; push room track per turn, silence at menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:02:44 +02:00
35ad4e6f1a feat(loader): per-room music field + demo rooms.yaml entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:58:13 +02:00
a38bb6e4f3 fix(io): idempotent MusicController.level; assert gain forwarding + shutdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:55:27 +02:00
7f64c9f9e3 feat(io): MusicController + MusicBackend (testable music logic)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:50:58 +02:00
a1fd56b389 test(io): assert MEDIUM.isOn() too 2026-06-01 22:49:20 +02:00
d0e290c723 feat(io): MusicLevel enum (Off/Low/Med/High)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:47:40 +02:00
36f60863ad docs: implementation plan for GUI background music
7 TDD tasks: MusicLevel, MusicController+MusicBackend, Room music field,
GameIO seams + loop/app wiring, Settings music level + SettingsMenu->GameIO
refactor, OggMusicBackend + deps + SwingIO wiring + docs, backlog note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:46:15 +02:00
a0e21a9baa docs: spec for GUI background music
OGG Vorbis (vorbisspi+jorbis) per-room music via rooms.yaml, external gitignored
music/ folder. Testable MusicController (idempotent track switch, null->silence,
OFF disables) over an isolated OggMusicBackend; GameIO.setMusic/setMusicLevel
no-op on console, played by SwingIO. Settings gains a Music level Off/Low/Med/High
(default Medium). Console stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:21:10 +02:00
ffc4d7acab Merge: suppress endings during the tutorial 2026-06-01 22:09:06 +02:00
20d076832d fix(game): suppress endings while the tutorial is active
A command during the walkthrough (e.g. use front_door -> fled) no longer ends
the game mid-tutorial; a pending ending fires once the tutorial finishes or is
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:09:06 +02:00
c5993d1236 Merge interactive start-of-game tutorial into develop
A skippable interactive walkthrough shown on New Game: a TutorialGuide layered
onto the real game loop makes the player perform the core commands
(look→examine→take→inventory→use→go→go to) in the starting room, with verb-/
alias-aware completion (movement steps require an actual room change), gentle
hints on wrong-but-valid input, and closing tips for the rest. Data-driven
(tutorial.yaml + standalone TutorialLoader); 'skip' ends it; loaded games never
show it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:03:59 +02:00
1e980a80e2 docs: note interactive start-of-game tutorial 2026-06-01 21:59:44 +02:00
19ea7667b0 refactor: consistent isActive() guard on tutorial onCommand; tidy App imports 2026-06-01 21:59:07 +02:00
2ed6a71cea feat: wire TutorialGuide into the game loop; show on New Game only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:53:27 +02:00
28871b4ddc fix(game): null-safe moved() + idempotent begin(); cover multi-step + go_to_room hint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:49:47 +02:00
546a7672bf feat(game): TutorialGuide interactive walkthrough engine
Alias-aware, stateful engine with verb/minArgs matching, go_direction/go_to_room
room-change detection, skip, and closing-tips on completion. 7 tests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:42:30 +02:00
f47a3e7ca5 test(tutorial): cover malformed tutorial.yaml -> WorldLoadException 2026-06-01 21:40:06 +02:00
95098fbb09 feat(tutorial): Tutorial model + TutorialLoader + tutorial.yaml content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:25:48 +02:00
5c9a8cb568 docs: implementation plan for interactive start tutorial
4 TDD tasks: Tutorial model+loader+tutorial.yaml, TutorialGuide engine,
Game+App wiring (New Game only), docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:23:06 +02:00
055a256b03 docs: spec for interactive start-of-game tutorial
Interactive walkthrough layered on the real game loop via a TutorialGuide
(optional in Game): core arc look->examine->take->inventory->use->go->go to,
verb-based alias-aware completion (movement steps require an actual room
change), closing tips, skippable via 'skip'. Data-driven tutorial.yaml +
separate TutorialLoader (not in World). Shown only on New Game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:10:13 +02:00
ba63ce7b47 Merge go to <room> pathfinding into develop
BFS auto-walk over visited rooms (respecting locked exits + darkness), routed
through GoCommand (go to <room> vs go <direction>). Console: silent traversal +
a start/middle/destination summary line. GUI: the minimap animates the player
moving room-by-room via a new GameIO.travelStep hook (SwingIO override, ~300ms;
console no-op). New stateless Pathfinder BFS in the game package. Trie
autocomplete deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:27:30 +02:00
930915da59 docs: sync spec unknown-target message with implemented wording 2026-06-01 20:27:19 +02:00
6d1b36f853 docs: mark go to <room> pathfinding implemented 2026-06-01 20:24:26 +02:00
c01c47a585 fix(command): combined unknown-target message; cover both go-to summary forms
- UX: unknown-target message now reads "don't know any direction or place
  called '...'" so direction typos (e.g. 'go nroth') make sense
- clarity: resolveVisited uses equalsIgnoreCase for id/name matching;
  add null-world guard comment pointing to CommandTestSupport
- Javadoc: note that 'to' filler word is stripped, making go-to forms equivalent
- tests: assert 'through the Hallway' in 3-node route; update unknown message
  assertion; add adjacentWalkUsesShortSummary covering the n==2 short-form path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:23:41 +02:00
a30c153de1 feat(command): go to <room> auto-walk (BFS path, animated map, summary)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:13:25 +02:00
663a78ed9e feat(game): Pathfinder BFS over visited rooms (locks + light aware)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:08:11 +02:00
7c1cdc545e feat(io): travelStep hook for animated auto-walk (console no-op, Swing override)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:04:17 +02:00
7b60230f1e docs: implementation plan for go to <room> pathfinding
4 TDD tasks: GameIO.travelStep hook (+SwingIO animation), Pathfinder BFS over
visited rooms (locks+light), GoCommand routing/resolve/walk/summary, docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:02:11 +02:00
d10632b800 docs: spec for go to <room> pathfinding
BFS over visited rooms (respecting locks + light), routed via GoCommand
(go to <room> vs go <direction>); silent console traversal + summary naming
start/middle/destination; GUI minimap animates per step via a new
GameIO.travelStep hook (SwingIO override, ~300ms). Trie autocomplete deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:03:26 +02:00
c10590028e Merge use X on Y item combinations into develop
Data-driven combination recipes (combinations.yaml: requires/consume/produce/
effects/response/failText), operands resolved from inventory or current room,
order-independent canonical pair key. UseCommand routes 2+ args to a new
Combinations engine; no parser change (on/with/to already fillers). Loader
validates ids + rejects self-combination/duplicate pairs at load. Demo:
matches + torch -> lit_torch (a working light source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:48:20 +02:00
966762fff9 docs: mark use X on Y item combination implemented 2026-06-01 17:45:16 +02:00
1bdae4f295 test(loader): assert exact item set incl. demo items (restore completeness check) 2026-06-01 17:44:32 +02:00
8d88157573 feat(loader): load combinations.yaml; demo matches+torch -> lit_torch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:38:17 +02:00
63af4c7641 docs(command): update UseCommand javadoc for the combination form 2026-06-01 17:32:58 +02:00
a89684807b feat(command): route use X on Y to the combination engine
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:07 +02:00
e7b9891771 fix(combinations): guard null response; reject self-combination at load
- Combinations.tryUse: only write response when non-null and non-blank,
  preventing NPE/IllegalArgument for effects-only recipes.
- CombinationFactory.fromDto: throw WorldLoadException when a == b,
  since item singletons make a self-combination impossible at runtime.
- Tests: +effectsOnlyRecipeAppliesEffectsWithoutProduceOrResponse
  (CombinationsTest 7→8), +rejectsSelfCombination
  (CombinationFactoryTest 4→5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:28:32 +02:00
89962b4b60 feat(game): Combinations engine for use X on Y
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 17:22:16 +02:00
667991df26 test(loader): cover unknown consume id rejection 2026-06-01 17:19:41 +02:00
3d183ad162 feat(loader): CombinationDto + CombinationFactory with id validation 2026-06-01 17:17:25 +02:00
9f9b0c0808 feat(model): World.combinations field + backward-compatible constructors 2026-06-01 17:13:05 +02:00
bbc4fc37f8 style(test): drop unused import in CombinationTest 2026-06-01 17:11:37 +02:00
b7094bf152 feat(model): Combination recipe record + order-independent key 2026-06-01 17:08:14 +02:00
2e48ec7221 docs: implementation plan for use X on Y combinations
7 TDD tasks: Combination model+key, World.combinations, CombinationDto+Factory
(id validation), Combinations engine, UseCommand routing, loader wiring + demo
(matches+torch -> lit_torch), docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:06:35 +02:00
34cbb7f158 docs: spec for use X on Y item combinations
Data-driven combination recipes (requires/consume/produce/effects/response/
failText), operands from inventory or current room, order-independent lookup,
generic-or-failText feedback. No parser change (on/with already fillers).
Minimal matches+torch -> lit_torch demo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:58:39 +02:00
6887102d19 feat(io): themed full-window menu overlay in the GUI
Replace the plain Swing JDialog menu with a glass-pane overlay that fills
the whole window: dark background matching the game palette, bundled font,
lavender heading, flat hover-highlighted buttons, Escape = last option. The
overlay hides the game chrome (HUD/map/quest panels) while in the menu and
rebuilds at the current frameScale*zoom on window resize, so it scales
correctly from startup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:21:46 +02:00
67 changed files with 6495 additions and 94 deletions

View File

@@ -43,3 +43,6 @@ build/
# Local save games and settings
saves/
# External background-music files (not bundled)
music/

View File

@@ -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
bewusst argloses `use X` ohne Targets gewählt → Command-Grammatik muss erweitert
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
abschließen = Win; *welche* Flags gesetzt sind, entscheidet das Ende.
@@ -174,6 +180,14 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
### 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**.
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.
@@ -189,8 +203,18 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
## Festgelegte Erweiterungs-Entscheidungen
> **Pathfinding** (BFS `go to <raum>`, Trie-Autocomplete) bleibt für eine
> spätere Runde im Scope.
> **Pathfinding** 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** 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 |
|---|---|

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

View 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).
```

View File

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

View 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.
```

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,18 @@
<version>${logback.version}</version>
</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 -->
<dependency>
<groupId>org.junit.jupiter</groupId>

View File

@@ -21,12 +21,15 @@ import thb.jeanluc.adventure.command.impl.UseCommand;
import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.game.GameSession;
import thb.jeanluc.adventure.game.TutorialGuide;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.text.Banner;
import thb.jeanluc.adventure.loader.TutorialLoader;
import thb.jeanluc.adventure.loader.WorldLoader;
import thb.jeanluc.adventure.menu.MainMenu;
import thb.jeanluc.adventure.menu.SettingsMenu;
import thb.jeanluc.adventure.model.Tutorial;
import thb.jeanluc.adventure.save.SaveException;
import thb.jeanluc.adventure.save.SaveService;
import thb.jeanluc.adventure.save.SaveSlotInfo;
@@ -64,24 +67,23 @@ public final class App {
SaveService saves = new SaveService();
SettingsStore settingsStore = new SettingsStore();
Settings settings = settingsStore.load();
ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;
SettingsMenu.apply(settings, consoleIo);
SettingsMenu.apply(settings, io);
boolean running = true;
while (running) {
switch (MainMenu.show(io)) {
case NEW_GAME -> play(io, saves, newSession(io));
case NEW_GAME -> play(io, saves, newSession(io), true);
case LOAD -> {
SaveSlotInfo slot = MainMenu.pickSlot(io, saves);
if (slot != null) {
try {
play(io, saves, saves.load(slot.slug()));
play(io, saves, saves.load(slot.slug()), false);
} catch (SaveException e) {
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;
}
}
@@ -96,7 +98,7 @@ public final class App {
}
/** 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);
ctx.setSaveCallback(() -> {
try {
@@ -128,10 +130,18 @@ public final class App {
registry.register(menu, "menu", "quit", "exit");
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.write(session.getWorld().getWelcomeMessage());
new LookCommand().execute(ctx, List.of());
game.run();
io.setMusic(null); // fade music out when returning to the menu
}
}

View File

@@ -4,38 +4,48 @@ 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 into the room reachable in a given direction.
* Usage: {@code go <direction>}.
* 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>}. The parser strips the filler word "to", so "go to
* &lt;room&gt;" and "go &lt;room&gt;" are equivalent.
*/
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'.");
ctx.getIo().write("Go where? Try 'go north' or 'go to <room>'.");
return;
}
Direction dir;
try {
dir = Direction.fromString(args.getFirst());
} catch (IllegalArgumentException e) {
ctx.getIo().write("I don't know which way '" + args.getFirst() + "' is.");
return;
Direction dir = Direction.fromString(args.getFirst());
moveDirection(ctx, dir);
} catch (IllegalArgumentException notADirection) {
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()) {
ctx.getIo().write("You can't go " + dir.getLabel() + " from here.");
return;
}
ExitLock lock = ctx.getPlayer().getCurrentRoom().getExitLocks().get(dir);
ExitLock lock = here.getExitLocks().get(dir);
if (lock != null && !Conditions.all(lock.requires(), ctx)) {
ctx.getIo().write(lock.blocked());
return;
@@ -49,8 +59,70 @@ public class GoCommand implements Command {
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
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";
}
}

View File

@@ -1,6 +1,7 @@
package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.Combinations;
import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.model.item.Item;
@@ -8,9 +9,10 @@ import java.util.List;
import java.util.Optional;
/**
* Invokes the item-specific {@link Item#use(GameContext)} action. The
* item must either be in the player's inventory or in the current room.
* Usage: {@code use <item-id>}.
* With one argument, invokes the item-specific {@link Item#use(GameContext)}
* action; the item must be in the player's inventory or the current room.
* 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 {
@@ -20,6 +22,10 @@ public class UseCommand implements Command {
ctx.getIo().write("Use what?");
return;
}
if (args.size() >= 2) {
Combinations.tryUse(ctx, args.get(0), args.get(1));
return;
}
String itemId = args.getFirst();
Optional<Item> item = ctx.getPlayer().findItem(itemId);
if (item.isEmpty()) {
@@ -34,6 +40,6 @@ public class UseCommand implements Command {
@Override
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";
}
}

View File

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

View File

@@ -32,12 +32,23 @@ public class Game {
/** Loop flag. Flipped to false by {@link #stop()}. */
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.
*/
public void run() {
publishHud();
maybeEnd();
if (tutorialGuide != null) {
tutorialGuide.begin(ctx);
}
while (running) {
String input = ctx.getIo().readLine();
if (input == null) {
@@ -47,6 +58,12 @@ public class Game {
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'.");
@@ -56,6 +73,9 @@ public class Game {
if (ctx.getSession().getTurn() % 10 == 0) {
ctx.save();
}
if (tutorialGuide != null && tutorialGuide.isActive()) {
tutorialGuide.onCommand(ctx, parsed);
}
}
publishHud();
maybeEnd();
@@ -64,6 +84,11 @@ public class Game {
/** Ends the game if any ending's conditions are met, printing its screen. */
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);
if (e != null) {
ctx.getIo().print(EndingEngine.render(e, ctx, ctx.getSession().getTurn()));
@@ -84,6 +109,7 @@ public class Game {
ctx.getPlayer().getCurrentRoom());
ctx.getIo().setMap(map);
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
}
/**

View File

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

View File

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

View File

@@ -44,6 +44,17 @@ public interface GameIO {
// 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). */
default void showMap(MapView view) {
print(AsciiMap.render(view, true));
@@ -54,6 +65,16 @@ public interface GameIO {
// 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. */
default void showQuests(QuestView view) {
print(QuestText.render(view));

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
@@ -22,6 +21,7 @@ import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.SimpleAttributeSet;
@@ -29,10 +29,13 @@ import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsEnvironment;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.Toolkit;
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. */
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. */
private static final double SIDE_RATIO = 0.28;
private static final int SIDE_MIN = 180;
private static final int SIDE_MAX = 460;
private final MusicController music = new MusicController(new OggMusicBackend());
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
private final JFrame frame;
private final JTextPane output;
@@ -138,6 +145,7 @@ public class SwingIO implements GameIO {
public void componentResized(ComponentEvent e) {
updateSideWidth();
applyFonts();
rescaleMenuOverlayIfVisible();
}
});
@@ -326,11 +334,31 @@ public class SwingIO implements GameIO {
SwingUtilities.invokeLater(() -> hud.setText(txt));
}
@Override
public void setMusic(String track) {
music.room(track);
}
@Override
public void setMusicLevel(MusicLevel level) {
music.level(level);
}
@Override
public void setMap(MapView 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
public void setQuests(QuestView view) {
quests.show(view);
@@ -348,41 +376,12 @@ public class SwingIO implements GameIO {
@Override
public int choose(String title, List<String> options) {
// Same EDT↔worker handoff as readLine: the worker blocks on take(),
// the EDT (button click / window close) offers the chosen index.
// Full-window overlay (glass pane), not a floating dialog: it covers the game
// 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<>();
SwingUtilities.invokeLater(() -> {
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);
});
SwingUtilities.invokeLater(() -> showMenuOverlay(title, options, picked));
try {
return picked.take();
} 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) {
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {

View File

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

View File

@@ -21,6 +21,7 @@ public final class RoomFactory {
public static Room shellFromDto(RoomDto dto) {
Room room = new Room(dto.id(), dto.name(), dto.description());
room.setDark(Boolean.TRUE.equals(dto.dark()));
room.setMusic(dto.music());
return room;
}
}

View File

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

View File

@@ -5,12 +5,14 @@ import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import thb.jeanluc.adventure.loader.dto.CombinationDto;
import thb.jeanluc.adventure.loader.dto.EndingDto;
import thb.jeanluc.adventure.loader.dto.GameDto;
import thb.jeanluc.adventure.loader.dto.ItemDto;
import thb.jeanluc.adventure.loader.dto.NpcDto;
import thb.jeanluc.adventure.loader.dto.QuestDto;
import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Combination;
import thb.jeanluc.adventure.model.Ending;
import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Player;
@@ -75,6 +77,8 @@ public class WorldLoader {
List<RoomDto> roomDtos = readList(basePath + "/rooms.yaml", RoomDto.class);
List<QuestDto> questDtos = readListOptional(basePath + "/quests.yaml", QuestDto.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);
requireUniqueIds("item", itemDtos.stream().map(ItemDto::id).toList());
@@ -104,6 +108,16 @@ public class WorldLoader {
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);
resolver.resolveRooms(roomDtos);
resolver.resolveNpcs(npcDtos);
@@ -115,7 +129,7 @@ public class WorldLoader {
Player player = new Player(start, gold);
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",
gameDto.title(), rooms.size(), items.size(), npcs.size());
return new LoadResult(world, player);

View File

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

View File

@@ -15,6 +15,7 @@ import java.util.Map;
* @param exitLocks optional condition-gates per exit direction
* @param descriptionStates optional condition-gated description variants
* @param dark whether this room is dark; nullable
* @param music optional GUI background-music track filename; null = none
*/
public record RoomDto(
String id,
@@ -25,11 +26,12 @@ public record RoomDto(
List<String> npcs,
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> descriptionStates,
Boolean dark
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);
this(id, name, description, exits, items, npcs, null, null, null, null);
}
}

View File

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

View File

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

View File

@@ -7,45 +7,41 @@ import thb.jeanluc.adventure.save.SettingsStore;
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 {
private SettingsMenu() {
}
/**
* Loops the settings screen until the player picks "Back", persisting and
* 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) {
/** 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 = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
} 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 {
return s;
}
store.save(s);
apply(s, consoleIo);
apply(s, io);
}
}
/** Applies colour/glyph settings to the console IO (no-op if null). */
public static void apply(Settings s, ConsoleIO consoleIo) {
if (consoleIo != null) {
consoleIo.setColorMode(s.colorMode());
consoleIo.setGlyphMode(s.glyphMode());
/** 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());
}
}

View File

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

View File

@@ -55,6 +55,10 @@ public class Room {
@Setter
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
* create the reverse connection — callers must set that up

View File

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

View File

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

View File

@@ -37,15 +37,24 @@ public class World {
/** Ordered list of endings (first matching one wins). */
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,
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,
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());
}
}

View File

@@ -1,11 +1,29 @@
package thb.jeanluc.adventure.save;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
/** Persisted user preferences (minimal: colour + glyph fidelity). */
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
/** Persisted user preferences (colour + glyph fidelity + music level). */
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() {
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE);
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
}
}

View File

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

View File

@@ -47,3 +47,19 @@
You ease the door shut again.
effects:
- { 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

View File

@@ -3,6 +3,7 @@
description: |
A dusty kitchen. Cobwebs hang from the rafters
and a letter lies on the table.
music: manor-theme.ogg
exits:
north: hallway
east: cellar
@@ -23,7 +24,7 @@
exits:
south: kitchen
west: library
items: []
items: [torch]
npcs: []
- id: library
@@ -33,7 +34,7 @@
An old chest stands in the corner.
exits:
east: hallway
items: [shovel]
items: [shovel, matches]
npcs: []
- id: cellar
@@ -55,6 +56,7 @@
name: Dungeon
description: |
A cramped stone room, black as pitch. A rusty generator squats in the corner.
music: dungeon-drone.ogg
dark: true
exits:
north: kitchen

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,8 @@ class WorldLoaderTest {
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
assertThat(result.world().getItems().keySet()).containsExactlyInAnyOrder("letter", "lamp", "key");
assertThat(result.world().getItems().keySet())
.containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch");
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");

View File

@@ -10,6 +10,7 @@ import thb.jeanluc.adventure.save.SaveSlotInfo;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
import java.nio.file.Path;
@@ -64,11 +65,18 @@ class MenuTest {
@Test
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
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("3");
Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO());
// UNICODE default toggles to ASCII; persisted
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);
}
@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());
}
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.save;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
import java.io.IOException;
import java.nio.file.Files;
@@ -19,6 +20,7 @@ class SettingsStoreTest {
Settings loaded = store.load();
assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON);
assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
assertThat(loaded.musicLevel()).isEqualTo(MusicLevel.MEDIUM);
}
@Test
@@ -34,4 +36,18 @@ class SettingsStoreTest {
Settings loaded = new SettingsStore(file).load();
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);
}
}

View 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"

View File

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

View File

@@ -17,3 +17,19 @@
id: key
name: 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

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