Merge feature/content-expansion into develop

Haunted Manor content expansion (14 rooms, one world + solvability test),
bundled background music and GUI font, wider sidebar, and the main-menu
Quit fix.
This commit is contained in:
2026-06-02 09:40:00 +02:00
29 changed files with 773 additions and 248 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +0,0 @@
title: Test Manor
version: "test"
startRoom: kitchen
startGold: 5
welcomeMessage: |
Welcome to the test.

View File

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

View File

@@ -1,10 +0,0 @@
- id: old_man
name: Old Man
description: stooped
greeting: |
greetings
reactions:
- onReceive: lamp
response: thanks
gives: key
consumes: lamp

View File

@@ -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: []

View File

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