Compare commits
7 Commits
dacac81382
...
92005c72f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 92005c72f5 | |||
| ecb8ab4183 | |||
| f0b1d625b9 | |||
| 863d58f6ec | |||
| 08d2dce10a | |||
| 1ae464dfe4 | |||
| 90311a7671 |
6
Semesterprojekt/.gitignore
vendored
6
Semesterprojekt/.gitignore
vendored
@@ -44,5 +44,7 @@ build/
|
|||||||
# Local save games and settings
|
# Local save games and settings
|
||||||
saves/
|
saves/
|
||||||
|
|
||||||
# External background-music files (not bundled)
|
# Source audio (MP3 originals); only the converted .ogg under
|
||||||
music/
|
# src/main/resources/music/ is tracked and bundled. Anchored so it
|
||||||
|
# ignores the top-level source folder but not the resources path.
|
||||||
|
/music/
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Background music (GUI)
|
# 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.
|
- Tracks live as `.ogg` (OGG Vorbis) files in `src/main/resources/music/`, so they
|
||||||
This folder is git-ignored and never bundled in the JAR.
|
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.
|
- 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
|
`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);
|
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.
|
seamlessly across rooms.
|
||||||
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
|
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
|
||||||
persisted to `saves/settings.json`. Off disables playback.
|
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.
|
- 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/<track>`) 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).
|
||||||
|
|||||||
@@ -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 <raum>`, 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.
|
||||||
@@ -64,8 +64,11 @@ public final class App {
|
|||||||
|
|
||||||
/** Runs the menu shell on the given IO until the player quits. */
|
/** Runs the menu shell on the given IO until the player quits. */
|
||||||
public static void run(GameIO io) {
|
public static void run(GameIO io) {
|
||||||
SaveService saves = new SaveService();
|
run(io, new SaveService(), new SettingsStore());
|
||||||
SettingsStore settingsStore = new SettingsStore();
|
}
|
||||||
|
|
||||||
|
/** Runs the menu shell with injectable persistence (for testing). */
|
||||||
|
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
|
||||||
Settings settings = settingsStore.load();
|
Settings settings = settingsStore.load();
|
||||||
SettingsMenu.apply(settings, io);
|
SettingsMenu.apply(settings, io);
|
||||||
|
|
||||||
@@ -88,6 +91,7 @@ public final class App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
io.write("Farewell.");
|
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. */
|
/** Loads a fresh world and binds it to a newly named session. */
|
||||||
|
|||||||
@@ -113,4 +113,14 @@ public interface GameIO {
|
|||||||
write("Please enter a number between 1 and " + options.size() + ".");
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ import javax.sound.sampled.AudioInputStream;
|
|||||||
import javax.sound.sampled.AudioSystem;
|
import javax.sound.sampled.AudioSystem;
|
||||||
import javax.sound.sampled.FloatControl;
|
import javax.sound.sampled.FloatControl;
|
||||||
import javax.sound.sampled.SourceDataLine;
|
import javax.sound.sampled.SourceDataLine;
|
||||||
import java.nio.file.Files;
|
import java.io.BufferedInputStream;
|
||||||
import java.nio.file.Path;
|
import java.io.InputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams OGG Vorbis tracks from the external {@code music/} folder through a
|
* Streams OGG Vorbis tracks bundled on the classpath under {@code /music/}
|
||||||
* {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
|
* through a {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding
|
||||||
* vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
|
* uses the vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a
|
||||||
* thread; missing/undecodable files are logged and stay silent. GUI-only —
|
* daemon thread; missing/undecodable files are logged and stay silent. GUI-only
|
||||||
* verified manually.
|
* — verified manually.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public final class OggMusicBackend implements MusicBackend {
|
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_MS = 400;
|
||||||
private static final int FADE_STEPS = 20;
|
private static final int FADE_STEPS = 20;
|
||||||
private static final int BUFFER = 4096;
|
private static final int BUFFER = 4096;
|
||||||
@@ -35,13 +35,13 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
public synchronized void fadeTo(String track, float gainDb) {
|
public synchronized void fadeTo(String track, float gainDb) {
|
||||||
stopCurrent();
|
stopCurrent();
|
||||||
targetGainDb = gainDb;
|
targetGainDb = gainDb;
|
||||||
Path file = Path.of(MUSIC_DIR, track);
|
String resource = MUSIC_DIR + track;
|
||||||
if (!Files.isReadable(file)) {
|
if (getClass().getResource(resource) == null) {
|
||||||
log.warn("Music file not found, staying silent: {}", file);
|
log.warn("Music resource not found, staying silent: {}", resource);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopping = false;
|
stopping = false;
|
||||||
playThread = new Thread(() -> playLoop(file, gainDb), "music");
|
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
|
||||||
playThread.setDaemon(true);
|
playThread.setDaemon(true);
|
||||||
playThread.start();
|
playThread.start();
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,14 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
playThread = null;
|
playThread = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void playLoop(Path file, float gainDb) {
|
private void playLoop(String resource, float gainDb) {
|
||||||
SourceDataLine l = null;
|
SourceDataLine l = null;
|
||||||
try {
|
try {
|
||||||
while (!stopping) {
|
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 base = in.getFormat();
|
||||||
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
||||||
base.getSampleRate(), 16, base.getChannels(),
|
base.getSampleRate(), 16, base.getChannels(),
|
||||||
@@ -106,7 +109,7 @@ public final class OggMusicBackend implements MusicBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Music playback failed for {}: {}", file, e.getMessage());
|
log.warn("Music playback failed for {}: {}", resource, e.getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
if (l != null) {
|
if (l != null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -69,10 +69,9 @@ public class SwingIO implements GameIO {
|
|||||||
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||||
private static final long TRAVEL_STEP_MS = 300L;
|
private static final long TRAVEL_STEP_MS = 300L;
|
||||||
|
|
||||||
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
|
/** Side-panel width as a fraction of the window width, with a lower bound only. */
|
||||||
private static final double SIDE_RATIO = 0.28;
|
private static final double SIDE_RATIO = 0.34;
|
||||||
private static final int SIDE_MIN = 180;
|
private static final int SIDE_MIN = 220;
|
||||||
private static final int SIDE_MAX = 460;
|
|
||||||
|
|
||||||
private final MusicController music = new MusicController(new OggMusicBackend());
|
private final MusicController music = new MusicController(new OggMusicBackend());
|
||||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
private final LinkedBlockingQueue<String> 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() {
|
private void updateSideWidth() {
|
||||||
int min = (int) (SIDE_MIN * uiScale);
|
int min = (int) (SIDE_MIN * uiScale);
|
||||||
int max = (int) (SIDE_MAX * uiScale);
|
int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO));
|
||||||
int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO)));
|
|
||||||
sidePanel.setPreferredSize(new Dimension(target, 0));
|
sidePanel.setPreferredSize(new Dimension(target, 0));
|
||||||
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
|
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
|
||||||
sidePanel.revalidate();
|
sidePanel.revalidate();
|
||||||
@@ -344,6 +342,21 @@ public class SwingIO implements GameIO {
|
|||||||
music.level(level);
|
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
|
@Override
|
||||||
public void setMap(MapView view) {
|
public void setMap(MapView view) {
|
||||||
map.show(view);
|
map.show(view);
|
||||||
|
|||||||
94
Semesterprojekt/src/main/resources/fonts/LICENSE
Normal file
94
Semesterprojekt/src/main/resources/fonts/LICENSE
Normal file
@@ -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.
|
||||||
BIN
Semesterprojekt/src/main/resources/fonts/game.ttf
Normal file
BIN
Semesterprojekt/src/main/resources/fonts/game.ttf
Normal file
Binary file not shown.
BIN
Semesterprojekt/src/main/resources/music/cellar-drone.ogg
Normal file
BIN
Semesterprojekt/src/main/resources/music/cellar-drone.ogg
Normal file
Binary file not shown.
BIN
Semesterprojekt/src/main/resources/music/lament.ogg
Normal file
BIN
Semesterprojekt/src/main/resources/music/lament.ogg
Normal file
Binary file not shown.
BIN
Semesterprojekt/src/main/resources/music/manor-theme.ogg
Normal file
BIN
Semesterprojekt/src/main/resources/music/manor-theme.ogg
Normal file
Binary file not shown.
BIN
Semesterprojekt/src/main/resources/music/musicbox.ogg
Normal file
BIN
Semesterprojekt/src/main/resources/music/musicbox.ogg
Normal file
Binary file not shown.
@@ -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
|
- a: matches
|
||||||
b: torch
|
b: torch
|
||||||
consume: [matches, torch]
|
consume: [matches, torch]
|
||||||
@@ -5,3 +9,42 @@
|
|||||||
response: |
|
response: |
|
||||||
You strike a match and touch it to the torch; it catches with a low hiss
|
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.
|
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.
|
||||||
|
|||||||
@@ -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"
|
title: "The Manor Reclaimed"
|
||||||
victory: true
|
victory: true
|
||||||
when: [{ flag: manor_secured }]
|
when: [{ flag: left_manor }, { flag: power_on }]
|
||||||
text: |
|
text: |
|
||||||
The lights hold steady and the whispers fade to nothing. Whatever held
|
Light burns in every window and the house's grip is broken. Whatever held
|
||||||
this place has loosened its grip. The manor is yours now.
|
this place has loosened — you walk out with your head high, the manor
|
||||||
|
reclaimed behind you.
|
||||||
|
|
||||||
- id: fled
|
- id: fled
|
||||||
title: "Into the Night"
|
title: "Into the Night"
|
||||||
victory: false
|
victory: false
|
||||||
when: [{ flag: fled }]
|
when: [{ flag: left_manor }]
|
||||||
text: |
|
text: |
|
||||||
You wrench the front door open and bolt into the dark. Safe — but the
|
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.
|
manor keeps its secrets, and they will keep you awake for years.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
title: Haunted Manor
|
title: Haunted Manor
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
startRoom: kitchen
|
startRoom: foyer
|
||||||
startGold: 0
|
startGold: 0
|
||||||
welcomeMessage: |
|
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.
|
Type 'help' to see available commands.
|
||||||
|
|||||||
@@ -1,57 +1,18 @@
|
|||||||
- type: readable
|
# Haunted Manor items. Two items — lit_torch and fuse — are intentionally left
|
||||||
id: letter
|
# unplaced: they exist only as the products of combinations. valve, generator
|
||||||
name: Letter
|
# and shrine are scenery "fixtures": technically takeable, but written as fixed
|
||||||
description: A crumpled piece of paper.
|
# in place; combinations resolve them from the room just as well as the bag.
|
||||||
readText: |
|
|
||||||
"Meet me at midnight in the cellar. - A."
|
# ── Light sources ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
- type: switchable
|
- type: switchable
|
||||||
id: lamp
|
id: lamp
|
||||||
name: Oil 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
|
initialState: false
|
||||||
light: true
|
light: true
|
||||||
onText: The lamp flares to life, casting a warm glow.
|
onText: The lamp flares to life, casting a warm, steady glow.
|
||||||
offText: You snuff out the lamp.
|
offText: You snuff out the lamp and the dark rushes back in.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
- type: plain
|
- type: plain
|
||||||
id: torch
|
id: torch
|
||||||
@@ -63,3 +24,101 @@
|
|||||||
name: Lit Torch
|
name: Lit Torch
|
||||||
description: A burning torch, throwing back the dark.
|
description: A burning torch, throwing back the dark.
|
||||||
light: true
|
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.
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
- id: old_man
|
- id: old_man
|
||||||
name: Old Man
|
name: Old Caretaker
|
||||||
description: A stooped old man with a grey beard.
|
description: A stooped old man with a grey beard, watching from among the shelves.
|
||||||
greeting: |
|
greeting: |
|
||||||
"Hello, traveller. If you bring me the oil lamp,
|
"Another lost soul in this house. Get the power running and tend to what
|
||||||
I will give you the key to the cellar."
|
waits upstairs — that is the only way out that's worth taking."
|
||||||
reactions:
|
# First matching line wins; otherwise the greeting is used.
|
||||||
- onReceive: lamp
|
|
||||||
response: |
|
|
||||||
"Thank you! Here, take this key."
|
|
||||||
gives: key
|
|
||||||
consumes: lamp
|
|
||||||
dialogue:
|
dialogue:
|
||||||
- when: [{ flag: power_on }]
|
- when: [{ flag: ghost_banished }]
|
||||||
text: |
|
text: |
|
||||||
"You got the power running! The whole house feels less...
|
"You laid the poor thing to rest. Bless you. Go now — the night is
|
||||||
watchful now."
|
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 }
|
||||||
|
|||||||
@@ -2,9 +2,17 @@
|
|||||||
title: "Bring the Manor to Life"
|
title: "Bring the Manor to Life"
|
||||||
autoStart: true
|
autoStart: true
|
||||||
stages:
|
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 }]
|
completion: [{ flag: power_on }]
|
||||||
- objective: "Earn the Old Man's brass key."
|
|
||||||
completion: [{ hasItem: key }]
|
|
||||||
onComplete:
|
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 }]
|
||||||
|
|||||||
@@ -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
|
- id: kitchen
|
||||||
name: Old Kitchen
|
name: Old Kitchen
|
||||||
description: |
|
description: |
|
||||||
A dusty kitchen. Cobwebs hang from the rafters
|
A dusty kitchen. Cobwebs hang from the rafters, a box of matches sits
|
||||||
and a letter lies on the table.
|
by the stove, and a letter lies on the table. A narrow stair descends
|
||||||
|
south into darkness.
|
||||||
music: manor-theme.ogg
|
music: manor-theme.ogg
|
||||||
exits:
|
exits:
|
||||||
north: hallway
|
west: grand_hall
|
||||||
east: cellar
|
south: cellar_stairs
|
||||||
south: dungeon
|
items: [matches, letter]
|
||||||
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]
|
|
||||||
npcs: []
|
npcs: []
|
||||||
|
|
||||||
- id: library
|
- id: library
|
||||||
name: Library
|
name: Library
|
||||||
description: |
|
description: |
|
||||||
Tall shelves crammed with mouldering books.
|
Tall shelves crammed with mouldering books. A rusty shovel leans in one
|
||||||
An old chest stands in the corner.
|
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:
|
exits:
|
||||||
east: hallway
|
east: grand_hall
|
||||||
items: [shovel, matches]
|
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: []
|
npcs: []
|
||||||
|
|
||||||
- id: cellar
|
- id: conservatory
|
||||||
name: Damp Cellar
|
name: Conservatory
|
||||||
description: |
|
description: |
|
||||||
Cold, damp, and very dark. You can barely
|
A glass-roofed room choked with dead ferns. Rusted plumbing lines the
|
||||||
make out shapes against the far wall.
|
walls, and a heavy iron valve wheel has been left on a potting bench.
|
||||||
|
music: manor-theme.ogg
|
||||||
exits:
|
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: []
|
items: []
|
||||||
npcs: []
|
npcs: []
|
||||||
descriptionStates:
|
exitLocks:
|
||||||
- when: [{ flag: power_on }]
|
- direction: north
|
||||||
text: |
|
requires: [{ flag: power_on }]
|
||||||
With the power back, a bare bulb buzzes overhead, revealing
|
blocked: |
|
||||||
shelves of dusty jars along the far wall.
|
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
|
# ── Cellar (dark — needs a carried light source) ────────────────────────────
|
||||||
name: Dungeon
|
|
||||||
|
- id: cellar_stairs
|
||||||
|
name: Cellar Landing
|
||||||
description: |
|
description: |
|
||||||
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
The bottom of the kitchen stair. The air is wet and close. Passages lead
|
||||||
music: dungeon-drone.ogg
|
east toward the sound of standing water and west toward a cold furnace.
|
||||||
|
music: cellar-drone.ogg
|
||||||
dark: true
|
dark: true
|
||||||
exits:
|
exits:
|
||||||
north: kitchen
|
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]
|
items: [generator]
|
||||||
npcs: []
|
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.
|
||||||
|
|||||||
@@ -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<String> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|||||||
class TutorialLoaderTest {
|
class TutorialLoaderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loadsTheTestFixture() {
|
void loadsTheShippedTutorial() {
|
||||||
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
|
// The toy fixture is gone, so this resolves the real /world/tutorial.yaml.
|
||||||
|
Tutorial t = new TutorialLoader().load();
|
||||||
assertThat(t.isEmpty()).isFalse();
|
assertThat(t.isEmpty()).isFalse();
|
||||||
assertThat(t.intro()).isNotBlank();
|
assertThat(t.intro()).isNotBlank();
|
||||||
assertThat(t.closingTips()).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().expect()).isEqualTo("look");
|
||||||
assertThat(t.steps().getFirst().minArgs()).isZero();
|
assertThat(t.steps().getFirst().minArgs()).isZero();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,41 @@ import thb.jeanluc.adventure.model.NpcReaction;
|
|||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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 {
|
class WorldLoaderTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_happyPath_buildsWorldFromTestFixtures() {
|
void load_happyPath_buildsTheShippedManor() {
|
||||||
WorldLoader.LoadResult result = new WorldLoader().load();
|
WorldLoader.LoadResult result = new WorldLoader().load();
|
||||||
|
|
||||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
assertThat(result.world().getTitle()).isEqualTo("Haunted Manor");
|
||||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
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())
|
assertThat(result.world().getItems().keySet())
|
||||||
.containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch");
|
.contains("lamp", "valve_wheel", "valve", "fuse", "generator",
|
||||||
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
"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().getCurrentRoom().getId()).isEqualTo("foyer");
|
||||||
assertThat(result.player().getGold()).isEqualTo(5);
|
assertThat(result.player().getGold()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
||||||
var loaded = new WorldLoader().load().world();
|
var loaded = new WorldLoader().load().world();
|
||||||
var kitchen = loaded.getRooms().get("kitchen");
|
var foyer = loaded.getRooms().get("foyer");
|
||||||
var hallway = loaded.getRooms().get("hallway");
|
var grandHall = loaded.getRooms().get("grand_hall");
|
||||||
|
|
||||||
assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway);
|
assertThat(foyer.getExit(Direction.NORTH)).contains(grandHall);
|
||||||
assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen);
|
assertThat(grandHall.getExit(Direction.SOUTH)).contains(foyer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -37,9 +48,9 @@ class WorldLoaderTest {
|
|||||||
var loaded = new WorldLoader().load().world();
|
var loaded = new WorldLoader().load().world();
|
||||||
var oldMan = loaded.getNpcs().get("old_man");
|
var oldMan = loaded.getNpcs().get("old_man");
|
||||||
|
|
||||||
assertThat(oldMan.getReactions()).containsKey("lamp");
|
assertThat(oldMan.getReactions()).containsKey("letter");
|
||||||
NpcReaction r = oldMan.reactionFor("lamp").orElseThrow();
|
NpcReaction r = oldMan.reactionFor("letter").orElseThrow();
|
||||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp"));
|
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("letter"));
|
||||||
assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key"));
|
assertThat(r.getGives()).isNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
title: Test Manor
|
|
||||||
version: "test"
|
|
||||||
startRoom: kitchen
|
|
||||||
startGold: 5
|
|
||||||
welcomeMessage: |
|
|
||||||
Welcome to the test.
|
|
||||||
@@ -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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
- id: old_man
|
|
||||||
name: Old Man
|
|
||||||
description: stooped
|
|
||||||
greeting: |
|
|
||||||
greetings
|
|
||||||
reactions:
|
|
||||||
- onReceive: lamp
|
|
||||||
response: thanks
|
|
||||||
gives: key
|
|
||||||
consumes: lamp
|
|
||||||
@@ -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: []
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
intro: |
|
|
||||||
Test intro.
|
|
||||||
steps:
|
|
||||||
- instruction: "Look: type 'look'."
|
|
||||||
expect: look
|
|
||||||
confirm: "Looked."
|
|
||||||
hint: "Type look."
|
|
||||||
- instruction: "Take: type 'take <item>'."
|
|
||||||
expect: take
|
|
||||||
minArgs: 1
|
|
||||||
confirm: "Took it."
|
|
||||||
hint: "Take something."
|
|
||||||
closingTips: |
|
|
||||||
Test tips.
|
|
||||||
Reference in New Issue
Block a user