diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore index b39385b..fb2eb16 100644 --- a/Semesterprojekt/.gitignore +++ b/Semesterprojekt/.gitignore @@ -44,5 +44,7 @@ build/ # Local save games and settings saves/ -# External background-music files (not bundled) -music/ +# Source audio (MP3 originals); only the converted .ogg under +# src/main/resources/music/ is tracked and bundled. Anchored so it +# ignores the top-level source folder but not the resources path. +/music/ diff --git a/Semesterprojekt/docs/music.md b/Semesterprojekt/docs/music.md index e0d0686..e94a6bd 100644 --- a/Semesterprojekt/docs/music.md +++ b/Semesterprojekt/docs/music.md @@ -1,9 +1,10 @@ # Background music (GUI) -Background music plays only in the Swing GUI. It is **data-driven and external**: +Background music plays only in the Swing GUI. It is **data-driven and bundled**: -- 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. +- Tracks live as `.ogg` (OGG Vorbis) files in `src/main/resources/music/`, so they + are on the classpath and ship inside the JAR — the game plays music out of the box, + with no external folder to set up. - 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); @@ -11,7 +12,21 @@ Background music plays only in the Swing GUI. It is **data-driven and external** 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. +- Missing tracks 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. +Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI; +`OggMusicBackend` streams each track from the classpath (`/music/`) on a +daemon thread. + +## Adding or replacing a track + +1. Convert your source audio to OGG Vorbis. With ffmpeg: + `ffmpeg -i in.mp3 -c:a libvorbis -q:a 5 out.ogg` + or with gstreamer (no ffmpeg needed): + `gst-launch-1.0 filesrc location=in.mp3 ! decodebin ! audioconvert ! audioresample ! vorbisenc quality=0.5 ! oggmux ! filesink location=out.ogg` +2. Drop the `.ogg` into `src/main/resources/music/`. +3. Reference it from a room's `music:` field in `rooms.yaml`. + +The four tracks shipped today: `manor-theme.ogg` (ground floor), +`cellar-drone.ogg` (cellar), `musicbox.ogg` (upper floor), `lament.ogg` (chapel). diff --git a/Semesterprojekt/docs/superpowers/specs/2026-06-02-content-expansion-design.md b/Semesterprojekt/docs/superpowers/specs/2026-06-02-content-expansion-design.md new file mode 100644 index 0000000..2a294d5 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/specs/2026-06-02-content-expansion-design.md @@ -0,0 +1,220 @@ +# Spec: Content-Ausbau — „Haunted Manor" (14 Räume) + +Stand: 2026-06-02. Backlog #4 (+ #5b Mechanik-Spine). Erster reiner **Content**- +Ausbau: die Engines stehen alle — diese Erweiterung ist überwiegend **YAML** +(rooms/items/npcs/quests/endings/combinations), plus die nötigen Test-Anpassungen. + +## 1. Kontext & Ziel + +Die Welt hat heute 5 Räume und eine Mini-Demo. Ziel: eine **kondensierte, designte** +Spielwelt (~14 Räume, 3 Ebenen) mit **einem zentralen Bogen** („Strom wiederherstellen +→ entkommen") der den Spieler raumweise führt und **alle fünf Mechaniken stapelt**, +plus ein optionaler **Geister-Strang** (den ruhelosen Geist zur Ruhe betten) für die +beste Ende-Variante. **Kein Hard-Fail** (keine Tod/Lose-Enden), puzzle-fokussiert. + +Bestätigte Entscheidungen (Brainstorming 2026-06-02): +- **Größe**: ~14 Räume, kondensiert/handgebaut (kein Füllmaterial). +- **Zentraler Bogen**: Strom wiederherstellen (drain → fuse → generator → `power_on`) + schaltet das Obergeschoss frei; **leichter Geister-Strang** speist die Enden. +- **Enden**: 3, priorisiert, **kein Hard-Fail**: *Into the Night* (geflohen) / + *Manor Reclaimed* (Strom + raus) / *Spirit at Rest* (Strom + Geist gebannt + raus). +- **Mechanik-Abdeckung**: Licht/Dunkelheit (Keller), verschlossene Exits (Treppe via + Strom, Haustür = Ausgang), Flags, Item-Kombination (mehrfach), Quest-Kette + Enden, + `go to `, Musik pro Raum. + +## 2. Scope + +**In Scope (Content, YAML in `src/main/resources/world/`):** +- `rooms.yaml`: 14 Räume, Exits (N/S/E/W), `dark`, `music`, exitLocks, descriptionStates. +- `items.yaml`: ~16 Items (siehe §5), inkl. produzierter (unplatzierter) Items. +- `combinations.yaml`: 5 Rezepte (§6). +- `npcs.yaml`: der `old_man` (Hinweis-NPC, flag-bedingte Dialoge, 1 Reaktion). +- `quests.yaml`: `restore_power` (autoStart, 2 Stages) + `lay_to_rest` (per Effekt gestartet). +- `endings.yaml`: 3 priorisierte Enden. +- `game.yaml`: Titel/Startraum/Welcome aktualisieren (Start = `foyer`). +- `tutorial.yaml`: ggf. minimal anpassen (Start ist jetzt `foyer`; der `go`-Schritt + führt nach Norden in die Grand Hall — bestehende verb-basierte Schritte bleiben gültig). + +**In Scope (Tests / eine Welt):** +- **Toy-Test-Fixture entfernen** (`src/test/resources/world/*`), damit die loader-basierten + Tests die **echte ausgelieferte Welt** prüfen (heute beschattet das Fixture die + Produktionswelt → Produktion ist ungetestet). `src/test/resources/badtut/` bleibt + (separate Basis für den Malformed-Test). +- `WorldLoaderTest` + `TutorialLoaderTest` an die echte Welt anpassen (Form-Invarianten + statt Toy-Fixture-Werte). +- Neuer **`WorldSolvableTest`**: läuft den kritischen Pfad programmatisch (Bewegung + + `Combinations.tryUse` + Flag-Checks) und prüft, dass `power_on`, `ghost_banished` und + ein Sieg-Ende erreichbar sind — automatische Lösbarkeits-Garantie. + +**Out of Scope:** +- Engine-/Code-Änderungen (außer Tests) — alle Mechaniken existieren bereits. +- Audiodateien (externer `music/`-Ordner; YAML referenziert nur Namen). +- Neue Mechaniken (Zahlenschloss/Code-Eingabe etc.) — bewusst vermieden (nur + vorhandene item+item-Kombinationen, Flags, Locks, Licht). +- Hard-Fail/Tod-Enden. + +## 3. Weltkarte (Kompass-Exits, ≤4 pro Raum — keine UP/DOWN; `Direction` ist N/S/E/W) + +Ebenen sind abstrakt über Kompassrichtungen verdrahtet (wie heute Keller=Süd etc.). + +| Raum | Exits | dark | Items | NPC | Lock | +|---|---|---|---|---|---| +| **foyer** (START) | N→grand_hall | – | front_door, **lamp** | – | – | +| **grand_hall** | S→foyer, N→dining_room, E→kitchen, W→library | – | – | – | – | +| **kitchen** | W→grand_hall, S→cellar_stairs | – | matches, letter | – | – | +| **library** | E→grand_hall | – | shovel, journal | old_man | – | +| **dining_room** | S→grand_hall, N→conservatory, E→grand_staircase | – | torch, portrait | – | – | +| **conservatory** | S→dining_room | – | valve_wheel | – | – | +| **grand_staircase** | W→dining_room, N→landing | – | – | – | **N→landing requires `power_on`** | +| **cellar_stairs** | N→kitchen, E→flooded_cellar, W→boiler_room | **dark** | – | – | – | +| **flooded_cellar** | W→cellar_stairs | **dark** | valve | – | – | +| **boiler_room** | E→cellar_stairs | **dark** | generator | – | – | +| **landing** | S→grand_staircase, N→master_bedroom, E→study, W→chapel | – | – | – | – | +| **master_bedroom** | S→landing | – | locket | – | – | +| **study** | W→landing | – | photograph | – | – | +| **chapel** | E→landing | – | shrine | – | – | + +Alle Verbindungen **bidirektional** (Gegen-Exit am Zielraum). Der Power-Lock sitzt nur +auf dem **Aufwärts**-Exit `grand_staircase → landing`; der Rückweg `landing → grand_staircase` +ist immer offen. Keller-Räume sind `dark` → Eintritt nur mit getragener Lichtquelle +(`GoCommand`/`Light` gaten das bereits). Der Strom versorgt das **Obergeschoss** (Treppe/ +Lift), nicht die Kellerverkabelung → der Keller bleibt dunkel (Lichtquelle nötig). + +## 4. Kritischer Pfad (Lösbarkeit) + +1. Erkunden Erdgeschoss; in der **kitchen** `lamp` + `matches` nehmen; `use lamp` → Lampe + an (Lichtquelle). (Alternativweg: `torch` aus dining + `use matches on torch` → `lit_torch`.) +2. **conservatory** (dining N): `valve_wheel` nehmen. +3. In den Keller (kitchen S→cellar_stairs — braucht Licht) → **flooded_cellar** (E): + `use valve_wheel on valve` → Flag `cellar_drained` + erhält `fuse`. +4. **boiler_room** (cellar_stairs W): `use fuse on generator` → Flag `power_on`. + (Quest `restore_power` abgeschlossen → startet `lay_to_rest`.) +5. `grand_staircase → landing` jetzt offen → Obergeschoss. +6. **master_bedroom**: `locket`. **study**: `photograph`. **chapel**: + `use locket on shrine` → `locket_placed`; `use photograph on shrine` + (requires `locket_placed`) → `ghost_banished`. +7. Zurück zur **foyer**: `use front_door` → Flag `left_manor` → Ende (Priorität, §8). + +## 5. Items (~16) + +| id | Typ | Ort | Zweck | +|---|---|---|---| +| lamp | switchable, light, initialState off | **foyer** (Start) | Primäre Lichtquelle (`use lamp` → an); zugleich gutartiges Tutorial-Ziel (examine/take/use) | +| matches | plain | kitchen | für lit_torch | +| letter | readable | kitchen | Lore; old_man-Reaktion | +| torch | plain | dining_room | für lit_torch | +| lit_torch | plain, light | **unplatziert** | Ergebnis matches+torch (alternative Lichtquelle) | +| shovel | plain | library | Flavor/Lore (kein kritischer Pfad) | +| journal | readable | library | Lore + Hinweise (Keller, Schrein) | +| valve_wheel | plain | conservatory | dräniert den Keller | +| valve | plain (Fixture) | flooded_cellar | Kombi-Ziel | +| fuse | plain | **unplatziert** | Ergebnis valve_wheel+valve | +| generator | plain (Fixture) | boiler_room | Kombi-Ziel → power_on | +| front_door | switchable, initialState off | foyer | `use front_door` → setFlag `left_manor` | +| locket | plain | master_bedroom | Relikt 1 | +| photograph | plain | study | Relikt 2 | +| shrine | plain (Fixture) | chapel | Kombi-Ziel der Relikte | +| portrait | readable | dining_room | Lore | + +„Fixture" = ein nicht-tragbares Element nur als Kombi-Operand gedacht (technisch ein +`plain`-Item im Raum; der Spieler könnte es theoretisch `take`n — unkritisch, optional +per Flavor-Beschreibung entmutigt). `lit_torch`/`fuse` sind in `items.yaml` definiert, +aber **keinem Raum zugewiesen** (entstehen nur per Kombination; `CombinationFactory` +validiert ihre Existenz, `SaveCodec` erfasst sie nach dem Erzeugen). + +## 6. Kombinationen (`combinations.yaml`, 5) + +| a | b | requires | consume | produce | effects | response (Kurz) | +|---|---|---|---|---|---|---| +| matches | torch | – | matches, torch | lit_torch | – | „Der Streichholz entflammt die Fackel." | +| valve_wheel | valve | – | – | fuse | setFlag cellar_drained | „Du kurbelst das Ventil; das Wasser sinkt und gibt eine Sicherung frei." | +| fuse | generator | – | fuse | – | setFlag power_on | „Die Sicherung sitzt; der Generator springt an, das Haus erwacht." | +| locket | shrine | – | locket | – | setFlag locket_placed | „Du legst das Medaillon auf den Schrein; die Luft erstarrt." | +| photograph | shrine | flag locket_placed | photograph | – | setFlag ghost_banished | erfolg: „…der Geist findet Frieden." / **failText**: „Der Schrein wirkt unvollständig — etwas fehlt noch." | + +(Reihenfolge-unabhängig; Operanden aus Inventar **oder** aktuellem Raum — Fixtures +liegen im Raum, Relikte/Werkzeuge im Inventar.) + +## 7. Flags, Quests, NPC + +**Flags:** `cellar_drained`, `power_on`, `locket_placed`, `ghost_banished`, `left_manor`. + +**`restore_power`** (autoStart): +- Stage 1 — Objective „Drain the flooded cellar." · completion: `flag cellar_drained`. +- Stage 2 — Objective „Get the manor's power running." · completion: `flag power_on`. +- onComplete: effects `[startQuest lay_to_rest, say "With the power back, the upper floor is reachable — and something stirs up there."]`. + +**`lay_to_rest`** (autoStart: **false**; via startQuest): +- Stage 1 — Objective „Lay the restless spirit to rest in the chapel." · completion: `flag ghost_banished`. + +**NPC `old_man`** (library): Greeting + **bedingte Dialoge**: +- ohne `cellar_drained`: Hinweis auf den gefluteten Keller / das Ventil. +- mit `power_on` ohne `ghost_banished`: Hinweis auf Obergeschoss/Schrein/Relikte. +- mit `ghost_banished`: dankbarer Abschiedssatz. +- **Reaktion**: onReceive `letter` → poignante Lore-Antwort, `consumes: letter`, + effects `[setFlag heard_tale]` (rein optional/Flavor; nicht auf dem kritischen Pfad). + +## 8. Enden (`endings.yaml`, Reihenfolge = Priorität, erstes Match gewinnt) + +1. **spirit_at_rest** — when `[flag left_manor, flag ghost_banished]` · victory **true** · + „Du trittst hinaus; hinter dir liegt das Haus endlich still. (Bester Ausgang.)" +2. **manor_reclaimed** — when `[flag left_manor, flag power_on]` · victory **true** · + „Licht in allen Fenstern, der Bann des Hauses gebrochen — du gehst erhobenen Hauptes." +3. **fled** — when `[flag left_manor]` · victory **false** · + „Du fliehst in die Nacht. Sicher — aber das Haus behält seine Geheimnisse." + +(`ghost_banished` ⇒ `power_on` ⇒ Obergeschoss erreicht, daher genügt in #1 die Kombination +`left_manor + ghost_banished`. Reihenfolge stellt sicher, dass das spezifischste Ende zuerst greift.) + +## 9. Beschreibungs-Varianten & Musik (Politur) + +- `flooded_cellar` descriptionState: vor `cellar_drained` „knietief im schwarzen Wasser…", + danach „das abgelassene Becken gibt den schlammigen Boden frei…". +- `chapel` descriptionState: vor `ghost_banished` „eine kalte Gegenwart drückt…", + danach „nur noch stilles Mondlicht…". +- Musik (optionale `music:`-Felder; Dateien extern, fehlend → still): Erdgeschoss + `manor-theme.ogg`; Keller `cellar-drone.ogg`; Obergeschoss `musicbox.ogg`; + chapel `lament.ogg`. + +## 10. Tests / „eine Welt"-Konsolidierung + +- **Entfernen**: `src/test/resources/world/{game,rooms,items,npcs,quests,endings,combinations,tutorial}.yaml` + (Toy-Fixture „Test Manor"). Danach lösen alle `new WorldLoader().load()`-Aufrufe in Tests + die **echte** Welt (target/classes/world). `src/test/resources/badtut/tutorial.yaml` bleibt. +- **`WorldLoaderTest`** anpassen: Titel „Haunted Manor", Startraum `foyer`, Raum-Anzahl (14), + Vorhandensein zentraler Items/Exits — Form-Invarianten der echten Welt statt Toy-Werte. +- **`TutorialLoaderTest`** anpassen: lädt jetzt das echte `tutorial.yaml` (Intro vorhanden, + ≥1 Schritt, erster expect=`look`) statt der 2-Schritt-Fixture. +- **`CombinationLoadingTest`**: bleibt gültig (Produktion enthält matches+torch→lit_torch). +- **`SaveServiceTest`/`MenuTest`**: laden irgendeine Welt → unverändert grün (State-Roundtrip). +- **Neu `WorldSolvableTest`** (lädt die echte Welt; treibt den kritischen Pfad rein über + Domäne + `Combinations.tryUse`, ohne IO/Loop): + - Lichtquelle besorgen, in den Keller, `valve_wheel`+`valve` → `cellar_drained` + fuse, + `fuse`+`generator` → `power_on`; Treppe-Lock öffnet; Relikte holen, + `locket`+`shrine` → `locket_placed`, `photograph`+`shrine` → `ghost_banished`; + `use front_door`; `EndingEngine.triggered` liefert **spirit_at_rest**. + - Zweiter Pfad: nur `power_on` + `left_manor` → **manor_reclaimed**; nur `left_manor` + → **fled** (prüft die Priorisierung + Lösbarkeit der Hauptkette). + +## 11. Architektur-Werte / Risiken + +- **Reiner Content** auf vorhandenen Engines; datengetrieben (YAML), keine neue Mechanik. +- **Kondensiert/designt**: jeder Raum hat Zweck (Item, Lock, Lore oder Knotenpunkt); ≤4 + Exits/Raum (Kompass-Constraint respektiert; keine UP/DOWN). +- **Lösbarkeit automatisiert** über `WorldSolvableTest` — ein gebrochener Lock/Flag, der die + Welt unlösbar macht, lässt den Build scheitern statt erst im Spiel aufzufallen. +- **Eine Welt** (Toy-Fixture entfernt) → die ausgelieferte Welt wird tatsächlich getestet; + Risiko: `WorldLoaderTest`/`TutorialLoaderTest` sind nun an den Content gekoppelt (gewollt + — sie prüfen die echte Welt). Form-Invarianten statt fragiler Exakt-Werte halten die + Kopplung beherrschbar. +- **Fixtures als `take`-bar**: `valve`/`generator`/`shrine` sind technisch tragbar; per + Beschreibung als ortsfest dargestellt. Wenn der Spieler eines `take`t, bricht nichts + (Kombination findet das Operand auch im Inventar). Bewusst akzeptiert. +- **Tutorial**: Start ist `foyer` mit der **`lamp`** als gutartigem Ziel — die Schritte + examine/take/use greifen auf die Lampe (der `use`-Schritt = „Lampe anzünden", exakt der + erste sinnvolle Zug), `go` führt nach Norden in die Grand Hall, `go to` zurück zur foyer + (zwei besuchte Räume). So landet ein neuer Spieler **nicht** versehentlich auf + `use front_door` (das `front_door` liegt zwar auch in der foyer, aber die Anweisungen + lenken auf die Lampe; und Enden sind während des Tutorials ohnehin unterdrückt). Die + bestehenden `tutorial.yaml`-Texte bleiben gültig; Start-Welcome/Banner in `game.yaml` + an „Haunted Manor / foyer" anpassen. diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index da51382..2cf6312 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -64,8 +64,11 @@ public final class App { /** Runs the menu shell on the given IO until the player quits. */ public static void run(GameIO io) { - SaveService saves = new SaveService(); - SettingsStore settingsStore = new SettingsStore(); + run(io, new SaveService(), new SettingsStore()); + } + + /** Runs the menu shell with injectable persistence (for testing). */ + static void run(GameIO io, SaveService saves, SettingsStore settingsStore) { Settings settings = settingsStore.load(); SettingsMenu.apply(settings, io); @@ -88,6 +91,7 @@ public final class App { } } io.write("Farewell."); + io.shutdown(); // GUI: close the window and exit; console: no-op } /** Loads a fresh world and binds it to a newly named session. */ diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index b1a22a1..33e1748 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -113,4 +113,14 @@ public interface GameIO { write("Please enter a number between 1 and " + options.size() + "."); } } + + /** + * Tears the frontend down after the main-menu loop ends (the player chose + * Quit). Console mode has nothing to release and exits naturally when + * {@code main} returns; the GUI overrides this to close its window and + * terminate the JVM, which would otherwise stay alive on the Swing EDT. + */ + default void shutdown() { + // no-op in text mode + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java index 72bee18..eeea0be 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java @@ -7,20 +7,20 @@ 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; +import java.io.BufferedInputStream; +import java.io.InputStream; /** - * 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. + * Streams OGG Vorbis tracks bundled on the classpath under {@code /music/} + * 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 String MUSIC_DIR = "/music/"; private static final int FADE_MS = 400; private static final int FADE_STEPS = 20; private static final int BUFFER = 4096; @@ -35,13 +35,13 @@ public final class OggMusicBackend implements MusicBackend { 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); + String resource = MUSIC_DIR + track; + if (getClass().getResource(resource) == null) { + log.warn("Music resource not found, staying silent: {}", resource); return; } stopping = false; - playThread = new Thread(() -> playLoop(file, gainDb), "music"); + playThread = new Thread(() -> playLoop(resource, gainDb), "music"); playThread.setDaemon(true); playThread.start(); } @@ -80,11 +80,14 @@ public final class OggMusicBackend implements MusicBackend { playThread = null; } - private void playLoop(Path file, float gainDb) { + private void playLoop(String resource, float gainDb) { SourceDataLine l = null; try { while (!stopping) { - try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) { + // Reopen the bundled resource each loop; BufferedInputStream gives + // the SPI the mark/reset support it needs to read the OGG header. + try (InputStream res = getClass().getResourceAsStream(resource); + AudioInputStream in = AudioSystem.getAudioInputStream(new BufferedInputStream(res))) { AudioFormat base = in.getFormat(); AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, base.getSampleRate(), 16, base.getChannels(), @@ -106,7 +109,7 @@ public final class OggMusicBackend implements MusicBackend { } } } catch (Exception e) { - log.warn("Music playback failed for {}: {}", file, e.getMessage()); + log.warn("Music playback failed for {}: {}", resource, e.getMessage()); } finally { if (l != null) { try { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index 77c6004..65184df 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -69,10 +69,9 @@ public class SwingIO implements GameIO { /** 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; + /** Side-panel width as a fraction of the window width, with a lower bound only. */ + private static final double SIDE_RATIO = 0.34; + private static final int SIDE_MIN = 220; private final MusicController music = new MusicController(new OggMusicBackend()); private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); @@ -191,11 +190,10 @@ public class SwingIO implements GameIO { } } - /** Sizes the side panel to a fraction of the window width, clamped to [min, max] (scaled). */ + /** Sizes the side panel to a fraction of the window width, with a scaled lower bound. */ private void updateSideWidth() { int min = (int) (SIDE_MIN * uiScale); - int max = (int) (SIDE_MAX * uiScale); - int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO))); + int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO)); sidePanel.setPreferredSize(new Dimension(target, 0)); questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32))); sidePanel.revalidate(); @@ -344,6 +342,21 @@ public class SwingIO implements GameIO { music.level(level); } + /** + * Closes the GUI when the player quits from the main menu. Stops music, + * disposes the window, and terminates the JVM — mirroring the native + * window-close (EXIT_ON_CLOSE); without this the EDT keeps the process + * alive and the dismissed menu overlay just reveals the game underneath. + */ + @Override + public void shutdown() { + music.shutdown(); + SwingUtilities.invokeLater(() -> { + frame.dispose(); + System.exit(0); + }); + } + @Override public void setMap(MapView view) { map.show(view); diff --git a/Semesterprojekt/src/main/resources/fonts/LICENSE b/Semesterprojekt/src/main/resources/fonts/LICENSE new file mode 100644 index 0000000..76d939d --- /dev/null +++ b/Semesterprojekt/src/main/resources/fonts/LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2019 - Present, Microsoft Corporation, +with Reserved Font Name Cascadia Code. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/Semesterprojekt/src/main/resources/fonts/game.ttf b/Semesterprojekt/src/main/resources/fonts/game.ttf new file mode 100644 index 0000000..444d648 Binary files /dev/null and b/Semesterprojekt/src/main/resources/fonts/game.ttf differ diff --git a/Semesterprojekt/src/main/resources/music/cellar-drone.ogg b/Semesterprojekt/src/main/resources/music/cellar-drone.ogg new file mode 100644 index 0000000..3aa30a6 Binary files /dev/null and b/Semesterprojekt/src/main/resources/music/cellar-drone.ogg differ diff --git a/Semesterprojekt/src/main/resources/music/lament.ogg b/Semesterprojekt/src/main/resources/music/lament.ogg new file mode 100644 index 0000000..bc89462 Binary files /dev/null and b/Semesterprojekt/src/main/resources/music/lament.ogg differ diff --git a/Semesterprojekt/src/main/resources/music/manor-theme.ogg b/Semesterprojekt/src/main/resources/music/manor-theme.ogg new file mode 100644 index 0000000..e1dce7f Binary files /dev/null and b/Semesterprojekt/src/main/resources/music/manor-theme.ogg differ diff --git a/Semesterprojekt/src/main/resources/music/musicbox.ogg b/Semesterprojekt/src/main/resources/music/musicbox.ogg new file mode 100644 index 0000000..4d13ade Binary files /dev/null and b/Semesterprojekt/src/main/resources/music/musicbox.ogg differ diff --git a/Semesterprojekt/src/main/resources/world/combinations.yaml b/Semesterprojekt/src/main/resources/world/combinations.yaml index 3bde52e..67ac8ee 100644 --- a/Semesterprojekt/src/main/resources/world/combinations.yaml +++ b/Semesterprojekt/src/main/resources/world/combinations.yaml @@ -1,3 +1,7 @@ +# Five recipes drive the whole manor. Operands are matched order-independently +# and may come from the inventory or the current room (so the cellar/chapel +# fixtures combine in place). Each sets the flag that unlocks the next step. + - a: matches b: torch consume: [matches, torch] @@ -5,3 +9,42 @@ 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. + +- a: valve_wheel + b: valve + produce: fuse + effects: + - { setFlag: cellar_drained } + response: | + You fit the wheel and crank it hard. The valve groans, the black water + drops away — and a ceramic fuse, washed loose, settles at your feet. + +- a: fuse + b: generator + consume: [fuse] + effects: + - { setFlag: power_on } + response: | + The fuse seats with a snap. The generator coughs, catches, and roars — + and somewhere above you the manor's lights stutter awake. + +- a: locket + b: shrine + consume: [locket] + effects: + - { setFlag: locket_placed } + response: | + You lay the silver locket in the shrine's niche. The air goes utterly + still, as though the house were holding its breath. + +- a: photograph + b: shrine + requires: [{ flag: locket_placed }] + consume: [photograph] + effects: + - { setFlag: ghost_banished } + response: | + You set the photograph beside the locket. A long sigh moves through the + chapel, and the cold presence loosens — the spirit finds its peace. + failText: | + The shrine feels unfinished — something else belongs here first. diff --git a/Semesterprojekt/src/main/resources/world/endings.yaml b/Semesterprojekt/src/main/resources/world/endings.yaml index cab9b91..4d313e6 100644 --- a/Semesterprojekt/src/main/resources/world/endings.yaml +++ b/Semesterprojekt/src/main/resources/world/endings.yaml @@ -1,14 +1,29 @@ -- id: victory +# Order is priority: the first ending whose conditions all hold wins. The most +# specific (best) ending is listed first. ghost_banished implies power_on +# implies the upper floor was reached, so #1 needs only left_manor + ghost_banished. + +- id: spirit_at_rest + title: "Spirit at Rest" + victory: true + when: [{ flag: left_manor }, { flag: ghost_banished }] + text: | + You step out into the night. Behind you the manor is finally, utterly + still — no cold, no whispers, no weight. You set something free in there, + and it set you free in turn. The best way out there is. + +- id: manor_reclaimed title: "The Manor Reclaimed" victory: true - when: [{ flag: manor_secured }] + when: [{ flag: left_manor }, { flag: power_on }] text: | - The lights hold steady and the whispers fade to nothing. Whatever held - this place has loosened its grip. The manor is yours now. + Light burns in every window and the house's grip is broken. Whatever held + this place has loosened — you walk out with your head high, the manor + reclaimed behind you. + - id: fled title: "Into the Night" victory: false - when: [{ flag: fled }] + when: [{ flag: left_manor }] text: | You wrench the front door open and bolt into the dark. Safe — but the manor keeps its secrets, and they will keep you awake for years. diff --git a/Semesterprojekt/src/main/resources/world/game.yaml b/Semesterprojekt/src/main/resources/world/game.yaml index f38d152..eb9dd09 100644 --- a/Semesterprojekt/src/main/resources/world/game.yaml +++ b/Semesterprojekt/src/main/resources/world/game.yaml @@ -1,7 +1,9 @@ title: Haunted Manor version: "1.0" -startRoom: kitchen +startRoom: foyer startGold: 0 welcomeMessage: | - Welcome to the Haunted Manor. + The gate clangs shut behind you. The Haunted Manor looms — dark, cold, + and waiting. Restore its power, lay its restless spirit to rest, and find + your way back out into the night. Type 'help' to see available commands. diff --git a/Semesterprojekt/src/main/resources/world/items.yaml b/Semesterprojekt/src/main/resources/world/items.yaml index 1e77b41..7ceb5a3 100644 --- a/Semesterprojekt/src/main/resources/world/items.yaml +++ b/Semesterprojekt/src/main/resources/world/items.yaml @@ -1,57 +1,18 @@ -- type: readable - id: letter - name: Letter - description: A crumpled piece of paper. - readText: | - "Meet me at midnight in the cellar. - A." +# Haunted Manor items. Two items — lit_torch and fuse — are intentionally left +# unplaced: they exist only as the products of combinations. valve, generator +# and shrine are scenery "fixtures": technically takeable, but written as fixed +# in place; combinations resolve them from the room just as well as the bag. + +# ── Light sources ─────────────────────────────────────────────────────────── - type: switchable id: lamp name: Oil Lamp - description: An old oil lamp, heavy with fuel. + description: An old oil lamp, heavy with fuel. Lighting it would push back the dark. initialState: false light: true - onText: The lamp flares to life, casting a warm glow. - offText: You snuff out the lamp. - -- type: plain - id: shovel - name: Shovel - description: A rusty shovel. Sturdy enough to dig. - -- type: plain - id: key - name: Brass Key - description: A small brass key, polished from use. - -- type: switchable - id: generator - name: Generator - description: A rusty generator with a heavy lever. - initialState: false - onText: | - You heave the lever. Somewhere deep in the manor, the lights flicker on. - offText: | - You pull the lever back; the manor falls dark again. - effects: - - { setFlag: power_on } - -- type: switchable - id: front_door - name: Front Door - description: The heavy front door. It would let you leave the manor for good. - initialState: false - onText: | - You haul the front door open and step out into the cold night. - offText: | - 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. + onText: The lamp flares to life, casting a warm, steady glow. + offText: You snuff out the lamp and the dark rushes back in. - type: plain id: torch @@ -63,3 +24,101 @@ name: Lit Torch description: A burning torch, throwing back the dark. light: true + +- type: plain + id: matches + name: Box of Matches + description: A small box of dry matches. They rattle when you shake it. + +# ── Power-restoration chain ────────────────────────────────────────────────── + +- type: plain + id: valve_wheel + name: Valve Wheel + description: A heavy iron wheel, sized to fit a drainage valve. + +- type: plain + id: valve + name: Drainage Valve + description: | + A rusted drainage valve set into the cellar wall. Its wheel is missing — + it is part of the house, not something you can carry off. + +- type: plain + id: fuse + name: Ceramic Fuse + description: A fat ceramic fuse, the kind that feeds a generator's main bus. + +- type: plain + id: generator + name: Generator + description: | + A rusty generator with an empty fuse socket. It is bolted to the floor — + you will have to work on it where it stands. + +# ── Spirit / relic chain ───────────────────────────────────────────────────── + +- type: plain + id: locket + name: Silver Locket + description: A tarnished silver locket. Something is engraved inside the clasp. + +- type: plain + id: photograph + name: Faded Photograph + description: A faded photograph of two figures standing before the manor. + +- type: plain + id: shrine + name: Stone Shrine + description: | + A small stone shrine with a shallow offering niche. It is mortared into + the chapel floor — a place to leave something, not to take it. + +# ── Exit ───────────────────────────────────────────────────────────────────── + +- type: switchable + id: front_door + name: Front Door + description: The heavy front door. It would let you leave the manor for good. + initialState: false + onText: | + You haul the front door open and step out into the cold night air. + offText: | + You ease the door shut again. + effects: + - { setFlag: left_manor } + +# ── Lore & flavour ─────────────────────────────────────────────────────────── + +- type: readable + id: letter + name: Letter + description: A crumpled piece of paper, addressed to no one. + readText: | + "If you are reading this, the power has failed again. The fuse is in the + flooded cellar — drain it first. And whatever you hear upstairs at night: + it only wants to be remembered. — A." + +- type: readable + id: journal + name: Caretaker's Journal + description: A leather journal in a careful, anxious hand. + readText: | + "Day 9. The generator died and the cellar flooded the same week. I keep + the valve wheel in the conservatory now. Day 14. The chapel shrine takes + the locket — but it wants the photograph too, in that order, or nothing + happens at all." + +- type: readable + id: portrait + name: Family Portrait + description: A faded oil portrait above the dining-room hearth. + readText: | + A stern family poses before the manor. A small brass plate reads only: + "They never did leave." + +- type: plain + id: shovel + name: Shovel + description: A rusty shovel. Sturdy enough to dig, though nothing here needs digging. diff --git a/Semesterprojekt/src/main/resources/world/npcs.yaml b/Semesterprojekt/src/main/resources/world/npcs.yaml index 2abdb34..0560b2e 100644 --- a/Semesterprojekt/src/main/resources/world/npcs.yaml +++ b/Semesterprojekt/src/main/resources/world/npcs.yaml @@ -1,17 +1,29 @@ - id: old_man - name: Old Man - description: A stooped old man with a grey beard. + name: Old Caretaker + description: A stooped old man with a grey beard, watching from among the shelves. greeting: | - "Hello, traveller. If you bring me the oil lamp, - I will give you the key to the cellar." - reactions: - - onReceive: lamp - response: | - "Thank you! Here, take this key." - gives: key - consumes: lamp + "Another lost soul in this house. Get the power running and tend to what + waits upstairs — that is the only way out that's worth taking." + # First matching line wins; otherwise the greeting is used. dialogue: - - when: [{ flag: power_on }] + - when: [{ flag: ghost_banished }] text: | - "You got the power running! The whole house feels less... - watchful now." + "You laid the poor thing to rest. Bless you. Go now — the night is + clear and the house will hold its peace." + - when: [{ flag: power_on }, { notFlag: ghost_banished }] + text: | + "The lights are back — feel how the house has quieted? Climb the stair. + In the chapel there's a shrine: give it the locket, then the photograph, + in that order." + - when: [{ notFlag: cellar_drained }] + text: | + "Cold down there, isn't it? The cellar's flooded over the fuse. Find the + valve wheel — the conservatory, I think — and drain it." + reactions: + - onReceive: letter + response: | + "Ah... that letter. I wrote it, long ago, for whoever came after. So + someone finally read it. Keep going — you're closer than they ever got." + consumes: letter + effects: + - { setFlag: heard_tale } diff --git a/Semesterprojekt/src/main/resources/world/quests.yaml b/Semesterprojekt/src/main/resources/world/quests.yaml index 0ad1a63..dc2e7f4 100644 --- a/Semesterprojekt/src/main/resources/world/quests.yaml +++ b/Semesterprojekt/src/main/resources/world/quests.yaml @@ -2,9 +2,17 @@ title: "Bring the Manor to Life" autoStart: true stages: - - objective: "Get the power running." + - objective: "Drain the flooded cellar." + completion: [{ flag: cellar_drained }] + - objective: "Get the manor's power running." completion: [{ flag: power_on }] - - objective: "Earn the Old Man's brass key." - completion: [{ hasItem: key }] onComplete: - - { setFlag: manor_secured } + - { startQuest: lay_to_rest } + - { say: "With the power back, the upper floor is reachable — and something stirs up there." } + +- id: lay_to_rest + title: "Lay the Spirit to Rest" + autoStart: false + stages: + - objective: "Lay the restless spirit to rest in the chapel." + completion: [{ flag: ghost_banished }] diff --git a/Semesterprojekt/src/main/resources/world/rooms.yaml b/Semesterprojekt/src/main/resources/world/rooms.yaml index 9eb5078..aa6092d 100644 --- a/Semesterprojekt/src/main/resources/world/rooms.yaml +++ b/Semesterprojekt/src/main/resources/world/rooms.yaml @@ -1,64 +1,199 @@ +# Haunted Manor — 14 rooms across three levels, wired over compass exits. +# Ground floor (manor-theme), cellar (cellar-drone, dark), upper floor +# (musicbox / lament). The upward stair is power-locked; everything else is open. + +# ── Ground floor ────────────────────────────────────────────────────────── + +- id: foyer + name: Entrance Foyer + description: | + A grand, decaying foyer. The heavy front door stands at your back; an + oil lamp waits on a side table. A wide archway opens north into the hall. + music: manor-theme.ogg + exits: + north: grand_hall + items: [front_door, lamp] + npcs: [] + +- id: grand_hall + name: Grand Hall + description: | + A cavernous hall, the hub of the house. Passages branch in every + direction: the foyer to the south, a dining room north, the kitchen + east, and the library west. + music: manor-theme.ogg + exits: + south: foyer + north: dining_room + east: kitchen + west: library + items: [] + npcs: [] + - id: kitchen name: Old Kitchen description: | - A dusty kitchen. Cobwebs hang from the rafters - and a letter lies on the table. + A dusty kitchen. Cobwebs hang from the rafters, a box of matches sits + by the stove, and a letter lies on the table. A narrow stair descends + south into darkness. music: manor-theme.ogg exits: - north: hallway - east: cellar - south: dungeon - items: [letter, lamp, front_door] - npcs: [old_man] - exitLocks: - - direction: east - requires: [{ flag: power_on }] - blocked: | - The cellar door is held shut by a magnetic lock — dead without power. - -- id: hallway - name: Dark Hallway - description: | - A long, dimly lit hallway. It smells musty. - Faint footsteps echo somewhere far away. - exits: - south: kitchen - west: library - items: [torch] + west: grand_hall + south: cellar_stairs + items: [matches, letter] npcs: [] - id: library name: Library description: | - Tall shelves crammed with mouldering books. - An old chest stands in the corner. + Tall shelves crammed with mouldering books. A rusty shovel leans in one + corner and a leather journal lies open on a reading stand. A stooped old + man keeps watch among the stacks. + music: manor-theme.ogg exits: - east: hallway - items: [shovel, matches] + east: grand_hall + items: [shovel, journal] + npcs: [old_man] + +- id: dining_room + name: Dining Room + description: | + A long banquet table under a sheet of dust. An unlit torch rests in a + wall bracket and a faded portrait hangs above the hearth. A conservatory + lies north; a grand staircase rises to the east. + music: manor-theme.ogg + exits: + south: grand_hall + north: conservatory + east: grand_staircase + items: [torch, portrait] npcs: [] -- id: cellar - name: Damp Cellar +- id: conservatory + name: Conservatory description: | - Cold, damp, and very dark. You can barely - make out shapes against the far wall. + A glass-roofed room choked with dead ferns. Rusted plumbing lines the + walls, and a heavy iron valve wheel has been left on a potting bench. + music: manor-theme.ogg exits: - west: kitchen + south: dining_room + items: [valve_wheel] + npcs: [] + +- id: grand_staircase + name: Grand Staircase + description: | + A sweeping staircase climbs into the gloom of the upper floor. The dining + room lies west. Without power, the lift gate at the top is sealed shut. + music: manor-theme.ogg + exits: + west: dining_room + north: landing items: [] npcs: [] - descriptionStates: - - when: [{ flag: power_on }] - text: | - With the power back, a bare bulb buzzes overhead, revealing - shelves of dusty jars along the far wall. + exitLocks: + - direction: north + requires: [{ flag: power_on }] + blocked: | + The lift gate at the head of the stairs is bolted by a dead electric + lock. Until the manor has power, the upper floor stays out of reach. -- id: dungeon - name: Dungeon +# ── Cellar (dark — needs a carried light source) ──────────────────────────── + +- id: cellar_stairs + name: Cellar Landing description: | - A cramped stone room, black as pitch. A rusty generator squats in the corner. - music: dungeon-drone.ogg + The bottom of the kitchen stair. The air is wet and close. Passages lead + east toward the sound of standing water and west toward a cold furnace. + music: cellar-drone.ogg dark: true exits: north: kitchen + east: flooded_cellar + west: boiler_room + items: [] + npcs: [] + +- id: flooded_cellar + name: Flooded Cellar + description: | + You stand knee-deep in black, freezing water. A drainage valve juts from + the wall, its wheel long since removed. + music: cellar-drone.ogg + dark: true + exits: + west: cellar_stairs + items: [valve] + npcs: [] + descriptionStates: + - when: [{ flag: cellar_drained }] + text: | + The drained basin has given up its muddy floor. The valve still + stands open against the wall, water trickling away below. + +- id: boiler_room + name: Boiler Room + description: | + A cramped stone room behind the furnace. A rusty generator squats in the + corner, an empty fuse socket gaping in its housing. + music: cellar-drone.ogg + dark: true + exits: + east: cellar_stairs items: [generator] npcs: [] + +# ── Upper floor (reachable only once the power is on) ─────────────────────── + +- id: landing + name: Upper Landing + description: | + A railed landing above the hall, lit now by the restored lights. Doors + open north to the master bedroom, east to a study, and west to a small + chapel. The staircase falls away to the south. + music: musicbox.ogg + exits: + south: grand_staircase + north: master_bedroom + east: study + west: chapel + items: [] + npcs: [] + +- id: master_bedroom + name: Master Bedroom + description: | + A grand four-poster bed, its drapes rotted to lace. On the nightstand, + catching the light, lies a silver locket. + music: musicbox.ogg + exits: + south: landing + items: [locket] + npcs: [] + +- id: study + name: Study + description: | + A cluttered writing study. Papers lie scattered across the desk, and a + framed photograph has fallen face-up among them. + music: musicbox.ogg + exits: + west: landing + items: [photograph] + npcs: [] + +- id: chapel + name: Manor Chapel + description: | + A tiny private chapel. A stone shrine stands at the far end beneath a + narrow window. A cold presence presses against you here. + music: lament.ogg + exits: + east: landing + items: [shrine] + npcs: [] + descriptionStates: + - when: [{ flag: ghost_banished }] + text: | + A tiny private chapel. The shrine stands quiet beneath the window, + and only still moonlight remains. The cold presence is gone. diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/AppQuitTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/AppQuitTest.java new file mode 100644 index 0000000..9629376 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/AppQuitTest.java @@ -0,0 +1,52 @@ +package thb.jeanluc.adventure; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.save.SaveService; +import thb.jeanluc.adventure.save.SettingsStore; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression test for the main-menu Quit: choosing Quit must end the menu loop + * AND tell the frontend to tear down. Previously {@code run} just returned, + * which exits the console but left the Swing window open (the menu overlay + * closed and the game reappeared underneath). We assert the frontend-agnostic + * contract: the Quit path invokes {@link thb.jeanluc.adventure.io.GameIO#shutdown()}. + */ +class AppQuitTest { + + /** Fake IO that always picks the last menu option (Quit) and records shutdown. */ + private static final class QuitIO extends TestIO { + boolean shutdownCalled; + int chooseCalls; + + @Override + public int choose(String title, List options) { + chooseCalls++; + return options.size() - 1; // Quit is the last main-menu option + } + + @Override + public void shutdown() { + shutdownCalled = true; + } + } + + @Test + void choosingQuit_tearsDownTheFrontend(@TempDir Path tmp) { + QuitIO io = new QuitIO(); + SaveService saves = new SaveService(tmp.resolve("saves")); + SettingsStore settings = new SettingsStore(tmp.resolve("settings.json")); + + App.run(io, saves, settings); + + assertThat(io.shutdownCalled).isTrue(); + assertThat(io.chooseCalls).isEqualTo(1); // straight to Quit, no game started + assertThat(io.allOutput()).contains("Farewell."); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java index a167f6e..97f4db7 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/TutorialLoaderTest.java @@ -9,12 +9,13 @@ 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 + void loadsTheShippedTutorial() { + // The toy fixture is gone, so this resolves the real /world/tutorial.yaml. + Tutorial t = new TutorialLoader().load(); assertThat(t.isEmpty()).isFalse(); assertThat(t.intro()).isNotBlank(); assertThat(t.closingTips()).isNotBlank(); - assertThat(t.steps()).hasSize(2); + assertThat(t.steps()).isNotEmpty(); assertThat(t.steps().getFirst().expect()).isEqualTo("look"); assertThat(t.steps().getFirst().minArgs()).isZero(); } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java index 9eba348..de50b0f 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldLoaderTest.java @@ -6,30 +6,41 @@ import thb.jeanluc.adventure.model.NpcReaction; import static org.assertj.core.api.Assertions.assertThat; +/** + * Loads the real, shipped Haunted Manor world (the toy test fixture has been + * removed, so {@code new WorldLoader().load()} resolves the production YAML). + * Assertions check form-invariants of the delivered world rather than fragile + * exact values, so adding flavour rooms/items won't break the suite — but a + * broken start room, missing core item, or unwired exit will. + */ class WorldLoaderTest { @Test - void load_happyPath_buildsWorldFromTestFixtures() { + void load_happyPath_buildsTheShippedManor() { WorldLoader.LoadResult result = new WorldLoader().load(); - assertThat(result.world().getTitle()).isEqualTo("Test Manor"); - assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway"); + assertThat(result.world().getTitle()).isEqualTo("Haunted Manor"); + assertThat(result.world().getRooms()).hasSize(14); + assertThat(result.world().getRooms().keySet()) + .contains("foyer", "grand_hall", "cellar_stairs", "boiler_room", + "grand_staircase", "landing", "chapel"); assertThat(result.world().getItems().keySet()) - .containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch"); - assertThat(result.world().getNpcs().keySet()).containsExactly("old_man"); + .contains("lamp", "valve_wheel", "valve", "fuse", "generator", + "locket", "photograph", "shrine", "front_door", "lit_torch"); + assertThat(result.world().getNpcs().keySet()).contains("old_man"); - assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen"); - assertThat(result.player().getGold()).isEqualTo(5); + assertThat(result.player().getCurrentRoom().getId()).isEqualTo("foyer"); + assertThat(result.player().getGold()).isZero(); } @Test void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() { var loaded = new WorldLoader().load().world(); - var kitchen = loaded.getRooms().get("kitchen"); - var hallway = loaded.getRooms().get("hallway"); + var foyer = loaded.getRooms().get("foyer"); + var grandHall = loaded.getRooms().get("grand_hall"); - assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway); - assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen); + assertThat(foyer.getExit(Direction.NORTH)).contains(grandHall); + assertThat(grandHall.getExit(Direction.SOUTH)).contains(foyer); } @Test @@ -37,9 +48,9 @@ class WorldLoaderTest { var loaded = new WorldLoader().load().world(); var oldMan = loaded.getNpcs().get("old_man"); - assertThat(oldMan.getReactions()).containsKey("lamp"); - NpcReaction r = oldMan.reactionFor("lamp").orElseThrow(); - assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp")); - assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key")); + assertThat(oldMan.getReactions()).containsKey("letter"); + NpcReaction r = oldMan.reactionFor("letter").orElseThrow(); + assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("letter")); + assertThat(r.getGives()).isNull(); } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldSolvableTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldSolvableTest.java new file mode 100644 index 0000000..4517a49 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/loader/WorldSolvableTest.java @@ -0,0 +1,133 @@ +package thb.jeanluc.adventure.loader; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.game.Combinations; +import thb.jeanluc.adventure.game.Conditions; +import thb.jeanluc.adventure.game.EndingEngine; +import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.game.GameSession; +import thb.jeanluc.adventure.game.Light; +import thb.jeanluc.adventure.game.QuestEngine; +import thb.jeanluc.adventure.io.TestIO; +import thb.jeanluc.adventure.model.Direction; +import thb.jeanluc.adventure.model.Ending; +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.SwitchableItem; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Automated solvability guarantee for the shipped Haunted Manor: walks the full + * critical path purely through the domain ({@link Combinations#tryUse}, flag + * checks, light/lock gating) — no IO loop, no command parsing. If a broken lock + * or flag makes the world unwinnable, this build fails instead of the player + * discovering it in-game. + */ +class WorldSolvableTest { + + private GameContext freshGame() { + WorldLoader.LoadResult loaded = new WorldLoader().load(); + GameContext ctx = new GameContext( + new GameSession(loaded.world(), loaded.player(), "test"), new TestIO()); + ctx.setSaveCallback(() -> { }); // quest completion autosaves; no-op it here + return ctx; + } + + @Test + void criticalPath_isWinnable_andYieldsSpiritAtRest() { + GameContext ctx = freshGame(); + World w = ctx.getWorld(); + Player p = ctx.getPlayer(); + + // The cellar is dark: with no carried light, you cannot see in it. + Room cellarStairs = w.getRooms().get("cellar_stairs"); + assertThat(Light.isLit(ctx, cellarStairs)).isFalse(); + + // Light the lamp (foyer) — now you carry a light source. + p.addItem(w.getItems().get("lamp")); + ((SwitchableItem) p.findItem("lamp").orElseThrow()).use(ctx); + assertThat(Light.isLit(ctx, cellarStairs)).isTrue(); + + // The upward stair is power-locked until the generator runs. + Room staircase = w.getRooms().get("grand_staircase"); + ExitLock stairLock = staircase.getExitLocks().get(Direction.NORTH); + assertThat(stairLock).isNotNull(); + assertThat(Conditions.all(stairLock.requires(), ctx)).isFalse(); + + // Drain the cellar: valve_wheel (carried) + valve (fixture in the room). + p.addItem(w.getItems().get("valve_wheel")); + p.setCurrentRoom(w.getRooms().get("flooded_cellar")); + Combinations.tryUse(ctx, "valve_wheel", "valve"); + assertThat(ctx.getState().isSet("cellar_drained")).isTrue(); + assertThat(p.hasItem("fuse")).isTrue(); + + // Restore power: fuse (carried) + generator (fixture in the room). + p.setCurrentRoom(w.getRooms().get("boiler_room")); + Combinations.tryUse(ctx, "fuse", "generator"); + assertThat(ctx.getState().isSet("power_on")).isTrue(); + assertThat(p.hasItem("fuse")).isFalse(); + + // With power on, the stair lock opens. + assertThat(Conditions.all(stairLock.requires(), ctx)).isTrue(); + + // One quest tick: restore_power completes and starts lay_to_rest. + QuestEngine.tick(ctx); + assertThat(ctx.getQuestLog().isCompleted("restore_power")).isTrue(); + assertThat(ctx.getQuestLog().isActive("lay_to_rest") + || ctx.getQuestLog().isCompleted("lay_to_rest")).isTrue(); + + // Chapel: photograph BEFORE locket must fail (requires locket_placed). + p.addItem(w.getItems().get("locket")); + p.addItem(w.getItems().get("photograph")); + p.setCurrentRoom(w.getRooms().get("chapel")); + Combinations.tryUse(ctx, "photograph", "shrine"); + assertThat(ctx.getState().isSet("ghost_banished")).isFalse(); + assertThat(p.hasItem("photograph")).isTrue(); + + // Correct order: locket, then photograph -> spirit banished. + Combinations.tryUse(ctx, "locket", "shrine"); + assertThat(ctx.getState().isSet("locket_placed")).isTrue(); + Combinations.tryUse(ctx, "photograph", "shrine"); + assertThat(ctx.getState().isSet("ghost_banished")).isTrue(); + + QuestEngine.tick(ctx); + assertThat(ctx.getQuestLog().isCompleted("lay_to_rest")).isTrue(); + + // Leave via the front door. + p.setCurrentRoom(w.getRooms().get("foyer")); + ((SwitchableItem) w.getItems().get("front_door")).use(ctx); + assertThat(ctx.getState().isSet("left_manor")).isTrue(); + + // Highest-priority ending wins: spirit_at_rest (a victory). + Ending end = EndingEngine.triggered(ctx); + assertThat(end).isNotNull(); + assertThat(end.id()).isEqualTo("spirit_at_rest"); + assertThat(end.victory()).isTrue(); + } + + @Test + void endingPriority_powerOnlyGivesManorReclaimed() { + GameContext ctx = freshGame(); + ctx.getState().set("power_on"); + ctx.getState().set("left_manor"); + + Ending end = EndingEngine.triggered(ctx); + assertThat(end).isNotNull(); + assertThat(end.id()).isEqualTo("manor_reclaimed"); + assertThat(end.victory()).isTrue(); + } + + @Test + void endingPriority_leftOnlyGivesFled() { + GameContext ctx = freshGame(); + ctx.getState().set("left_manor"); + + Ending end = EndingEngine.triggered(ctx); + assertThat(end).isNotNull(); + assertThat(end.id()).isEqualTo("fled"); + assertThat(end.victory()).isFalse(); + } +} diff --git a/Semesterprojekt/src/test/resources/world/combinations.yaml b/Semesterprojekt/src/test/resources/world/combinations.yaml deleted file mode 100644 index 3bde52e..0000000 --- a/Semesterprojekt/src/test/resources/world/combinations.yaml +++ /dev/null @@ -1,7 +0,0 @@ -- 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. diff --git a/Semesterprojekt/src/test/resources/world/game.yaml b/Semesterprojekt/src/test/resources/world/game.yaml deleted file mode 100644 index 0be3b8a..0000000 --- a/Semesterprojekt/src/test/resources/world/game.yaml +++ /dev/null @@ -1,6 +0,0 @@ -title: Test Manor -version: "test" -startRoom: kitchen -startGold: 5 -welcomeMessage: | - Welcome to the test. diff --git a/Semesterprojekt/src/test/resources/world/items.yaml b/Semesterprojekt/src/test/resources/world/items.yaml deleted file mode 100644 index a082c4a..0000000 --- a/Semesterprojekt/src/test/resources/world/items.yaml +++ /dev/null @@ -1,35 +0,0 @@ -- type: readable - id: letter - name: Letter - description: A note. - readText: | - hello - -- type: switchable - id: lamp - name: Lamp - description: A lamp. - initialState: false - onText: on - offText: off - -- type: plain - id: key - name: Key - description: A key. - -- 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 diff --git a/Semesterprojekt/src/test/resources/world/npcs.yaml b/Semesterprojekt/src/test/resources/world/npcs.yaml deleted file mode 100644 index c70f171..0000000 --- a/Semesterprojekt/src/test/resources/world/npcs.yaml +++ /dev/null @@ -1,10 +0,0 @@ -- id: old_man - name: Old Man - description: stooped - greeting: | - greetings - reactions: - - onReceive: lamp - response: thanks - gives: key - consumes: lamp diff --git a/Semesterprojekt/src/test/resources/world/rooms.yaml b/Semesterprojekt/src/test/resources/world/rooms.yaml deleted file mode 100644 index 559da36..0000000 --- a/Semesterprojekt/src/test/resources/world/rooms.yaml +++ /dev/null @@ -1,15 +0,0 @@ -- id: kitchen - name: Kitchen - description: kitchen desc - exits: - north: hallway - items: [letter, lamp] - npcs: [old_man] - -- id: hallway - name: Hallway - description: hallway desc - exits: - south: kitchen - items: [] - npcs: [] diff --git a/Semesterprojekt/src/test/resources/world/tutorial.yaml b/Semesterprojekt/src/test/resources/world/tutorial.yaml deleted file mode 100644 index 950bf44..0000000 --- a/Semesterprojekt/src/test/resources/world/tutorial.yaml +++ /dev/null @@ -1,14 +0,0 @@ -intro: | - Test intro. -steps: - - instruction: "Look: type 'look'." - expect: look - confirm: "Looked." - hint: "Type look." - - instruction: "Take: type 'take '." - expect: take - minArgs: 1 - confirm: "Took it." - hint: "Take something." -closingTips: | - Test tips.