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