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
|
||||
saves/
|
||||
|
||||
# External background-music files (not bundled)
|
||||
music/
|
||||
# Source audio (MP3 originals); only the converted .ogg under
|
||||
# src/main/resources/music/ is tracked and bundled. Anchored so it
|
||||
# ignores the top-level source folder but not the resources path.
|
||||
/music/
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Background music (GUI)
|
||||
|
||||
Background music plays only in the Swing GUI. It is **data-driven and external**:
|
||||
Background music plays only in the Swing GUI. It is **data-driven and bundled**:
|
||||
|
||||
- Put `.ogg` (OGG Vorbis) files in a `music/` folder next to where you run the game.
|
||||
This folder is git-ignored and never bundled in the JAR.
|
||||
- Tracks live as `.ogg` (OGG Vorbis) files in `src/main/resources/music/`, so they
|
||||
are on the classpath and ship inside the JAR — the game plays music out of the box,
|
||||
with no external folder to set up.
|
||||
- A room plays a track via an optional `music:` field in `rooms.yaml`, e.g.
|
||||
`music: manor-theme.ogg`. Entering a room with a different track fades the old one
|
||||
out and the new one in (sequential, not an overlapping cross-fade);
|
||||
@@ -11,7 +12,21 @@ Background music plays only in the Swing GUI. It is **data-driven and external**
|
||||
seamlessly across rooms.
|
||||
- Volume is the **Music** setting (Off / Low / Medium / High) in the Settings menu,
|
||||
persisted to `saves/settings.json`. Off disables playback.
|
||||
- Missing files are ignored (silent) — the game never crashes over absent audio.
|
||||
- Missing tracks are ignored (silent) — the game never crashes over absent audio.
|
||||
- The console version is always silent.
|
||||
|
||||
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.
|
||||
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI;
|
||||
`OggMusicBackend` streams each track from the classpath (`/music/<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. */
|
||||
public static void run(GameIO io) {
|
||||
SaveService saves = new SaveService();
|
||||
SettingsStore settingsStore = new SettingsStore();
|
||||
run(io, new SaveService(), new SettingsStore());
|
||||
}
|
||||
|
||||
/** Runs the menu shell with injectable persistence (for testing). */
|
||||
static void run(GameIO io, SaveService saves, SettingsStore settingsStore) {
|
||||
Settings settings = settingsStore.load();
|
||||
SettingsMenu.apply(settings, io);
|
||||
|
||||
@@ -88,6 +91,7 @@ public final class App {
|
||||
}
|
||||
}
|
||||
io.write("Farewell.");
|
||||
io.shutdown(); // GUI: close the window and exit; console: no-op
|
||||
}
|
||||
|
||||
/** Loads a fresh world and binds it to a newly named session. */
|
||||
|
||||
@@ -113,4 +113,14 @@ public interface GameIO {
|
||||
write("Please enter a number between 1 and " + options.size() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears the frontend down after the main-menu loop ends (the player chose
|
||||
* Quit). Console mode has nothing to release and exits naturally when
|
||||
* {@code main} returns; the GUI overrides this to close its window and
|
||||
* terminate the JVM, which would otherwise stay alive on the Swing EDT.
|
||||
*/
|
||||
default void shutdown() {
|
||||
// no-op in text mode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,20 @@ import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import javax.sound.sampled.FloatControl;
|
||||
import javax.sound.sampled.SourceDataLine;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Streams OGG Vorbis tracks from the external {@code music/} folder through a
|
||||
* {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding uses the
|
||||
* vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a daemon
|
||||
* thread; missing/undecodable files are logged and stay silent. GUI-only —
|
||||
* verified manually.
|
||||
* Streams OGG Vorbis tracks bundled on the classpath under {@code /music/}
|
||||
* through a {@link SourceDataLine}, looping, with ~400 ms gain fades. Decoding
|
||||
* uses the vorbisspi/jorbis {@code javax.sound} SPI. All playback runs on a
|
||||
* daemon thread; missing/undecodable files are logged and stay silent. GUI-only
|
||||
* — verified manually.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class OggMusicBackend implements MusicBackend {
|
||||
|
||||
private static final String MUSIC_DIR = "music";
|
||||
private static final String MUSIC_DIR = "/music/";
|
||||
private static final int FADE_MS = 400;
|
||||
private static final int FADE_STEPS = 20;
|
||||
private static final int BUFFER = 4096;
|
||||
@@ -35,13 +35,13 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
public synchronized void fadeTo(String track, float gainDb) {
|
||||
stopCurrent();
|
||||
targetGainDb = gainDb;
|
||||
Path file = Path.of(MUSIC_DIR, track);
|
||||
if (!Files.isReadable(file)) {
|
||||
log.warn("Music file not found, staying silent: {}", file);
|
||||
String resource = MUSIC_DIR + track;
|
||||
if (getClass().getResource(resource) == null) {
|
||||
log.warn("Music resource not found, staying silent: {}", resource);
|
||||
return;
|
||||
}
|
||||
stopping = false;
|
||||
playThread = new Thread(() -> playLoop(file, gainDb), "music");
|
||||
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
|
||||
playThread.setDaemon(true);
|
||||
playThread.start();
|
||||
}
|
||||
@@ -80,11 +80,14 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
playThread = null;
|
||||
}
|
||||
|
||||
private void playLoop(Path file, float gainDb) {
|
||||
private void playLoop(String resource, float gainDb) {
|
||||
SourceDataLine l = null;
|
||||
try {
|
||||
while (!stopping) {
|
||||
try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
|
||||
// Reopen the bundled resource each loop; BufferedInputStream gives
|
||||
// the SPI the mark/reset support it needs to read the OGG header.
|
||||
try (InputStream res = getClass().getResourceAsStream(resource);
|
||||
AudioInputStream in = AudioSystem.getAudioInputStream(new BufferedInputStream(res))) {
|
||||
AudioFormat base = in.getFormat();
|
||||
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
|
||||
base.getSampleRate(), 16, base.getChannels(),
|
||||
@@ -106,7 +109,7 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Music playback failed for {}: {}", file, e.getMessage());
|
||||
log.warn("Music playback failed for {}: {}", resource, e.getMessage());
|
||||
} finally {
|
||||
if (l != null) {
|
||||
try {
|
||||
|
||||
@@ -69,10 +69,9 @@ public class SwingIO implements GameIO {
|
||||
/** Delay between rooms while animating a go-to auto-walk, in milliseconds. */
|
||||
private static final long TRAVEL_STEP_MS = 300L;
|
||||
|
||||
/** Side-panel width as a fraction of the window width, clamped by the bounds below. */
|
||||
private static final double SIDE_RATIO = 0.28;
|
||||
private static final int SIDE_MIN = 180;
|
||||
private static final int SIDE_MAX = 460;
|
||||
/** Side-panel width as a fraction of the window width, with a lower bound only. */
|
||||
private static final double SIDE_RATIO = 0.34;
|
||||
private static final int SIDE_MIN = 220;
|
||||
|
||||
private final MusicController music = new MusicController(new OggMusicBackend());
|
||||
private final LinkedBlockingQueue<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() {
|
||||
int min = (int) (SIDE_MIN * uiScale);
|
||||
int max = (int) (SIDE_MAX * uiScale);
|
||||
int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO)));
|
||||
int target = Math.max(min, (int) (frame.getWidth() * SIDE_RATIO));
|
||||
sidePanel.setPreferredSize(new Dimension(target, 0));
|
||||
questScroll.setPreferredSize(new Dimension(target, (int) (frame.getHeight() * 0.32)));
|
||||
sidePanel.revalidate();
|
||||
@@ -344,6 +342,21 @@ public class SwingIO implements GameIO {
|
||||
music.level(level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the GUI when the player quits from the main menu. Stops music,
|
||||
* disposes the window, and terminates the JVM — mirroring the native
|
||||
* window-close (EXIT_ON_CLOSE); without this the EDT keeps the process
|
||||
* alive and the dismissed menu overlay just reveals the game underneath.
|
||||
*/
|
||||
@Override
|
||||
public void shutdown() {
|
||||
music.shutdown();
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
frame.dispose();
|
||||
System.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMap(MapView view) {
|
||||
map.show(view);
|
||||
|
||||
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
|
||||
b: torch
|
||||
consume: [matches, torch]
|
||||
@@ -5,3 +9,42 @@
|
||||
response: |
|
||||
You strike a match and touch it to the torch; it catches with a low hiss
|
||||
and burns steady. You now hold a lit torch.
|
||||
|
||||
- a: valve_wheel
|
||||
b: valve
|
||||
produce: fuse
|
||||
effects:
|
||||
- { setFlag: cellar_drained }
|
||||
response: |
|
||||
You fit the wheel and crank it hard. The valve groans, the black water
|
||||
drops away — and a ceramic fuse, washed loose, settles at your feet.
|
||||
|
||||
- a: fuse
|
||||
b: generator
|
||||
consume: [fuse]
|
||||
effects:
|
||||
- { setFlag: power_on }
|
||||
response: |
|
||||
The fuse seats with a snap. The generator coughs, catches, and roars —
|
||||
and somewhere above you the manor's lights stutter awake.
|
||||
|
||||
- a: locket
|
||||
b: shrine
|
||||
consume: [locket]
|
||||
effects:
|
||||
- { setFlag: locket_placed }
|
||||
response: |
|
||||
You lay the silver locket in the shrine's niche. The air goes utterly
|
||||
still, as though the house were holding its breath.
|
||||
|
||||
- a: photograph
|
||||
b: shrine
|
||||
requires: [{ flag: locket_placed }]
|
||||
consume: [photograph]
|
||||
effects:
|
||||
- { setFlag: ghost_banished }
|
||||
response: |
|
||||
You set the photograph beside the locket. A long sigh moves through the
|
||||
chapel, and the cold presence loosens — the spirit finds its peace.
|
||||
failText: |
|
||||
The shrine feels unfinished — something else belongs here first.
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
- id: victory
|
||||
# Order is priority: the first ending whose conditions all hold wins. The most
|
||||
# specific (best) ending is listed first. ghost_banished implies power_on
|
||||
# implies the upper floor was reached, so #1 needs only left_manor + ghost_banished.
|
||||
|
||||
- id: spirit_at_rest
|
||||
title: "Spirit at Rest"
|
||||
victory: true
|
||||
when: [{ flag: left_manor }, { flag: ghost_banished }]
|
||||
text: |
|
||||
You step out into the night. Behind you the manor is finally, utterly
|
||||
still — no cold, no whispers, no weight. You set something free in there,
|
||||
and it set you free in turn. The best way out there is.
|
||||
|
||||
- id: manor_reclaimed
|
||||
title: "The Manor Reclaimed"
|
||||
victory: true
|
||||
when: [{ flag: manor_secured }]
|
||||
when: [{ flag: left_manor }, { flag: power_on }]
|
||||
text: |
|
||||
The lights hold steady and the whispers fade to nothing. Whatever held
|
||||
this place has loosened its grip. The manor is yours now.
|
||||
Light burns in every window and the house's grip is broken. Whatever held
|
||||
this place has loosened — you walk out with your head high, the manor
|
||||
reclaimed behind you.
|
||||
|
||||
- id: fled
|
||||
title: "Into the Night"
|
||||
victory: false
|
||||
when: [{ flag: fled }]
|
||||
when: [{ flag: left_manor }]
|
||||
text: |
|
||||
You wrench the front door open and bolt into the dark. Safe — but the
|
||||
manor keeps its secrets, and they will keep you awake for years.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
title: Haunted Manor
|
||||
version: "1.0"
|
||||
startRoom: kitchen
|
||||
startRoom: foyer
|
||||
startGold: 0
|
||||
welcomeMessage: |
|
||||
Welcome to the Haunted Manor.
|
||||
The gate clangs shut behind you. The Haunted Manor looms — dark, cold,
|
||||
and waiting. Restore its power, lay its restless spirit to rest, and find
|
||||
your way back out into the night.
|
||||
Type 'help' to see available commands.
|
||||
|
||||
@@ -1,57 +1,18 @@
|
||||
- type: readable
|
||||
id: letter
|
||||
name: Letter
|
||||
description: A crumpled piece of paper.
|
||||
readText: |
|
||||
"Meet me at midnight in the cellar. - A."
|
||||
# Haunted Manor items. Two items — lit_torch and fuse — are intentionally left
|
||||
# unplaced: they exist only as the products of combinations. valve, generator
|
||||
# and shrine are scenery "fixtures": technically takeable, but written as fixed
|
||||
# in place; combinations resolve them from the room just as well as the bag.
|
||||
|
||||
# ── Light sources ───────────────────────────────────────────────────────────
|
||||
|
||||
- type: switchable
|
||||
id: lamp
|
||||
name: Oil Lamp
|
||||
description: An old oil lamp, heavy with fuel.
|
||||
description: An old oil lamp, heavy with fuel. Lighting it would push back the dark.
|
||||
initialState: false
|
||||
light: true
|
||||
onText: The lamp flares to life, casting a warm glow.
|
||||
offText: You snuff out the lamp.
|
||||
|
||||
- type: plain
|
||||
id: shovel
|
||||
name: Shovel
|
||||
description: A rusty shovel. Sturdy enough to dig.
|
||||
|
||||
- type: plain
|
||||
id: key
|
||||
name: Brass Key
|
||||
description: A small brass key, polished from use.
|
||||
|
||||
- type: switchable
|
||||
id: generator
|
||||
name: Generator
|
||||
description: A rusty generator with a heavy lever.
|
||||
initialState: false
|
||||
onText: |
|
||||
You heave the lever. Somewhere deep in the manor, the lights flicker on.
|
||||
offText: |
|
||||
You pull the lever back; the manor falls dark again.
|
||||
effects:
|
||||
- { setFlag: power_on }
|
||||
|
||||
- type: switchable
|
||||
id: front_door
|
||||
name: Front Door
|
||||
description: The heavy front door. It would let you leave the manor for good.
|
||||
initialState: false
|
||||
onText: |
|
||||
You haul the front door open and step out into the cold night.
|
||||
offText: |
|
||||
You ease the door shut again.
|
||||
effects:
|
||||
- { setFlag: fled }
|
||||
|
||||
- type: plain
|
||||
id: matches
|
||||
name: Box of Matches
|
||||
description: A small box of dry matches. They rattle when you shake it.
|
||||
onText: The lamp flares to life, casting a warm, steady glow.
|
||||
offText: You snuff out the lamp and the dark rushes back in.
|
||||
|
||||
- type: plain
|
||||
id: torch
|
||||
@@ -63,3 +24,101 @@
|
||||
name: Lit Torch
|
||||
description: A burning torch, throwing back the dark.
|
||||
light: true
|
||||
|
||||
- type: plain
|
||||
id: matches
|
||||
name: Box of Matches
|
||||
description: A small box of dry matches. They rattle when you shake it.
|
||||
|
||||
# ── Power-restoration chain ──────────────────────────────────────────────────
|
||||
|
||||
- type: plain
|
||||
id: valve_wheel
|
||||
name: Valve Wheel
|
||||
description: A heavy iron wheel, sized to fit a drainage valve.
|
||||
|
||||
- type: plain
|
||||
id: valve
|
||||
name: Drainage Valve
|
||||
description: |
|
||||
A rusted drainage valve set into the cellar wall. Its wheel is missing —
|
||||
it is part of the house, not something you can carry off.
|
||||
|
||||
- type: plain
|
||||
id: fuse
|
||||
name: Ceramic Fuse
|
||||
description: A fat ceramic fuse, the kind that feeds a generator's main bus.
|
||||
|
||||
- type: plain
|
||||
id: generator
|
||||
name: Generator
|
||||
description: |
|
||||
A rusty generator with an empty fuse socket. It is bolted to the floor —
|
||||
you will have to work on it where it stands.
|
||||
|
||||
# ── Spirit / relic chain ─────────────────────────────────────────────────────
|
||||
|
||||
- type: plain
|
||||
id: locket
|
||||
name: Silver Locket
|
||||
description: A tarnished silver locket. Something is engraved inside the clasp.
|
||||
|
||||
- type: plain
|
||||
id: photograph
|
||||
name: Faded Photograph
|
||||
description: A faded photograph of two figures standing before the manor.
|
||||
|
||||
- type: plain
|
||||
id: shrine
|
||||
name: Stone Shrine
|
||||
description: |
|
||||
A small stone shrine with a shallow offering niche. It is mortared into
|
||||
the chapel floor — a place to leave something, not to take it.
|
||||
|
||||
# ── Exit ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
- type: switchable
|
||||
id: front_door
|
||||
name: Front Door
|
||||
description: The heavy front door. It would let you leave the manor for good.
|
||||
initialState: false
|
||||
onText: |
|
||||
You haul the front door open and step out into the cold night air.
|
||||
offText: |
|
||||
You ease the door shut again.
|
||||
effects:
|
||||
- { setFlag: left_manor }
|
||||
|
||||
# ── Lore & flavour ───────────────────────────────────────────────────────────
|
||||
|
||||
- type: readable
|
||||
id: letter
|
||||
name: Letter
|
||||
description: A crumpled piece of paper, addressed to no one.
|
||||
readText: |
|
||||
"If you are reading this, the power has failed again. The fuse is in the
|
||||
flooded cellar — drain it first. And whatever you hear upstairs at night:
|
||||
it only wants to be remembered. — A."
|
||||
|
||||
- type: readable
|
||||
id: journal
|
||||
name: Caretaker's Journal
|
||||
description: A leather journal in a careful, anxious hand.
|
||||
readText: |
|
||||
"Day 9. The generator died and the cellar flooded the same week. I keep
|
||||
the valve wheel in the conservatory now. Day 14. The chapel shrine takes
|
||||
the locket — but it wants the photograph too, in that order, or nothing
|
||||
happens at all."
|
||||
|
||||
- type: readable
|
||||
id: portrait
|
||||
name: Family Portrait
|
||||
description: A faded oil portrait above the dining-room hearth.
|
||||
readText: |
|
||||
A stern family poses before the manor. A small brass plate reads only:
|
||||
"They never did leave."
|
||||
|
||||
- type: plain
|
||||
id: shovel
|
||||
name: Shovel
|
||||
description: A rusty shovel. Sturdy enough to dig, though nothing here needs digging.
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
- id: old_man
|
||||
name: Old Man
|
||||
description: A stooped old man with a grey beard.
|
||||
name: Old Caretaker
|
||||
description: A stooped old man with a grey beard, watching from among the shelves.
|
||||
greeting: |
|
||||
"Hello, traveller. If you bring me the oil lamp,
|
||||
I will give you the key to the cellar."
|
||||
reactions:
|
||||
- onReceive: lamp
|
||||
response: |
|
||||
"Thank you! Here, take this key."
|
||||
gives: key
|
||||
consumes: lamp
|
||||
"Another lost soul in this house. Get the power running and tend to what
|
||||
waits upstairs — that is the only way out that's worth taking."
|
||||
# First matching line wins; otherwise the greeting is used.
|
||||
dialogue:
|
||||
- when: [{ flag: power_on }]
|
||||
- when: [{ flag: ghost_banished }]
|
||||
text: |
|
||||
"You got the power running! The whole house feels less...
|
||||
watchful now."
|
||||
"You laid the poor thing to rest. Bless you. Go now — the night is
|
||||
clear and the house will hold its peace."
|
||||
- when: [{ flag: power_on }, { notFlag: ghost_banished }]
|
||||
text: |
|
||||
"The lights are back — feel how the house has quieted? Climb the stair.
|
||||
In the chapel there's a shrine: give it the locket, then the photograph,
|
||||
in that order."
|
||||
- when: [{ notFlag: cellar_drained }]
|
||||
text: |
|
||||
"Cold down there, isn't it? The cellar's flooded over the fuse. Find the
|
||||
valve wheel — the conservatory, I think — and drain it."
|
||||
reactions:
|
||||
- onReceive: letter
|
||||
response: |
|
||||
"Ah... that letter. I wrote it, long ago, for whoever came after. So
|
||||
someone finally read it. Keep going — you're closer than they ever got."
|
||||
consumes: letter
|
||||
effects:
|
||||
- { setFlag: heard_tale }
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
title: "Bring the Manor to Life"
|
||||
autoStart: true
|
||||
stages:
|
||||
- objective: "Get the power running."
|
||||
- objective: "Drain the flooded cellar."
|
||||
completion: [{ flag: cellar_drained }]
|
||||
- objective: "Get the manor's power running."
|
||||
completion: [{ flag: power_on }]
|
||||
- objective: "Earn the Old Man's brass key."
|
||||
completion: [{ hasItem: key }]
|
||||
onComplete:
|
||||
- { setFlag: manor_secured }
|
||||
- { startQuest: lay_to_rest }
|
||||
- { say: "With the power back, the upper floor is reachable — and something stirs up there." }
|
||||
|
||||
- id: lay_to_rest
|
||||
title: "Lay the Spirit to Rest"
|
||||
autoStart: false
|
||||
stages:
|
||||
- objective: "Lay the restless spirit to rest in the chapel."
|
||||
completion: [{ flag: ghost_banished }]
|
||||
|
||||
@@ -1,64 +1,199 @@
|
||||
# Haunted Manor — 14 rooms across three levels, wired over compass exits.
|
||||
# Ground floor (manor-theme), cellar (cellar-drone, dark), upper floor
|
||||
# (musicbox / lament). The upward stair is power-locked; everything else is open.
|
||||
|
||||
# ── Ground floor ──────────────────────────────────────────────────────────
|
||||
|
||||
- id: foyer
|
||||
name: Entrance Foyer
|
||||
description: |
|
||||
A grand, decaying foyer. The heavy front door stands at your back; an
|
||||
oil lamp waits on a side table. A wide archway opens north into the hall.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
north: grand_hall
|
||||
items: [front_door, lamp]
|
||||
npcs: []
|
||||
|
||||
- id: grand_hall
|
||||
name: Grand Hall
|
||||
description: |
|
||||
A cavernous hall, the hub of the house. Passages branch in every
|
||||
direction: the foyer to the south, a dining room north, the kitchen
|
||||
east, and the library west.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
south: foyer
|
||||
north: dining_room
|
||||
east: kitchen
|
||||
west: library
|
||||
items: []
|
||||
npcs: []
|
||||
|
||||
- id: kitchen
|
||||
name: Old Kitchen
|
||||
description: |
|
||||
A dusty kitchen. Cobwebs hang from the rafters
|
||||
and a letter lies on the table.
|
||||
A dusty kitchen. Cobwebs hang from the rafters, a box of matches sits
|
||||
by the stove, and a letter lies on the table. A narrow stair descends
|
||||
south into darkness.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
north: hallway
|
||||
east: cellar
|
||||
south: dungeon
|
||||
items: [letter, lamp, front_door]
|
||||
npcs: [old_man]
|
||||
exitLocks:
|
||||
- direction: east
|
||||
requires: [{ flag: power_on }]
|
||||
blocked: |
|
||||
The cellar door is held shut by a magnetic lock — dead without power.
|
||||
|
||||
- id: hallway
|
||||
name: Dark Hallway
|
||||
description: |
|
||||
A long, dimly lit hallway. It smells musty.
|
||||
Faint footsteps echo somewhere far away.
|
||||
exits:
|
||||
south: kitchen
|
||||
west: library
|
||||
items: [torch]
|
||||
west: grand_hall
|
||||
south: cellar_stairs
|
||||
items: [matches, letter]
|
||||
npcs: []
|
||||
|
||||
- id: library
|
||||
name: Library
|
||||
description: |
|
||||
Tall shelves crammed with mouldering books.
|
||||
An old chest stands in the corner.
|
||||
Tall shelves crammed with mouldering books. A rusty shovel leans in one
|
||||
corner and a leather journal lies open on a reading stand. A stooped old
|
||||
man keeps watch among the stacks.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
east: hallway
|
||||
items: [shovel, matches]
|
||||
east: grand_hall
|
||||
items: [shovel, journal]
|
||||
npcs: [old_man]
|
||||
|
||||
- id: dining_room
|
||||
name: Dining Room
|
||||
description: |
|
||||
A long banquet table under a sheet of dust. An unlit torch rests in a
|
||||
wall bracket and a faded portrait hangs above the hearth. A conservatory
|
||||
lies north; a grand staircase rises to the east.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
south: grand_hall
|
||||
north: conservatory
|
||||
east: grand_staircase
|
||||
items: [torch, portrait]
|
||||
npcs: []
|
||||
|
||||
- id: cellar
|
||||
name: Damp Cellar
|
||||
- id: conservatory
|
||||
name: Conservatory
|
||||
description: |
|
||||
Cold, damp, and very dark. You can barely
|
||||
make out shapes against the far wall.
|
||||
A glass-roofed room choked with dead ferns. Rusted plumbing lines the
|
||||
walls, and a heavy iron valve wheel has been left on a potting bench.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
west: kitchen
|
||||
south: dining_room
|
||||
items: [valve_wheel]
|
||||
npcs: []
|
||||
|
||||
- id: grand_staircase
|
||||
name: Grand Staircase
|
||||
description: |
|
||||
A sweeping staircase climbs into the gloom of the upper floor. The dining
|
||||
room lies west. Without power, the lift gate at the top is sealed shut.
|
||||
music: manor-theme.ogg
|
||||
exits:
|
||||
west: dining_room
|
||||
north: landing
|
||||
items: []
|
||||
npcs: []
|
||||
descriptionStates:
|
||||
- when: [{ flag: power_on }]
|
||||
text: |
|
||||
With the power back, a bare bulb buzzes overhead, revealing
|
||||
shelves of dusty jars along the far wall.
|
||||
exitLocks:
|
||||
- direction: north
|
||||
requires: [{ flag: power_on }]
|
||||
blocked: |
|
||||
The lift gate at the head of the stairs is bolted by a dead electric
|
||||
lock. Until the manor has power, the upper floor stays out of reach.
|
||||
|
||||
- id: dungeon
|
||||
name: Dungeon
|
||||
# ── Cellar (dark — needs a carried light source) ────────────────────────────
|
||||
|
||||
- id: cellar_stairs
|
||||
name: Cellar Landing
|
||||
description: |
|
||||
A cramped stone room, black as pitch. A rusty generator squats in the corner.
|
||||
music: dungeon-drone.ogg
|
||||
The bottom of the kitchen stair. The air is wet and close. Passages lead
|
||||
east toward the sound of standing water and west toward a cold furnace.
|
||||
music: cellar-drone.ogg
|
||||
dark: true
|
||||
exits:
|
||||
north: kitchen
|
||||
east: flooded_cellar
|
||||
west: boiler_room
|
||||
items: []
|
||||
npcs: []
|
||||
|
||||
- id: flooded_cellar
|
||||
name: Flooded Cellar
|
||||
description: |
|
||||
You stand knee-deep in black, freezing water. A drainage valve juts from
|
||||
the wall, its wheel long since removed.
|
||||
music: cellar-drone.ogg
|
||||
dark: true
|
||||
exits:
|
||||
west: cellar_stairs
|
||||
items: [valve]
|
||||
npcs: []
|
||||
descriptionStates:
|
||||
- when: [{ flag: cellar_drained }]
|
||||
text: |
|
||||
The drained basin has given up its muddy floor. The valve still
|
||||
stands open against the wall, water trickling away below.
|
||||
|
||||
- id: boiler_room
|
||||
name: Boiler Room
|
||||
description: |
|
||||
A cramped stone room behind the furnace. A rusty generator squats in the
|
||||
corner, an empty fuse socket gaping in its housing.
|
||||
music: cellar-drone.ogg
|
||||
dark: true
|
||||
exits:
|
||||
east: cellar_stairs
|
||||
items: [generator]
|
||||
npcs: []
|
||||
|
||||
# ── Upper floor (reachable only once the power is on) ───────────────────────
|
||||
|
||||
- id: landing
|
||||
name: Upper Landing
|
||||
description: |
|
||||
A railed landing above the hall, lit now by the restored lights. Doors
|
||||
open north to the master bedroom, east to a study, and west to a small
|
||||
chapel. The staircase falls away to the south.
|
||||
music: musicbox.ogg
|
||||
exits:
|
||||
south: grand_staircase
|
||||
north: master_bedroom
|
||||
east: study
|
||||
west: chapel
|
||||
items: []
|
||||
npcs: []
|
||||
|
||||
- id: master_bedroom
|
||||
name: Master Bedroom
|
||||
description: |
|
||||
A grand four-poster bed, its drapes rotted to lace. On the nightstand,
|
||||
catching the light, lies a silver locket.
|
||||
music: musicbox.ogg
|
||||
exits:
|
||||
south: landing
|
||||
items: [locket]
|
||||
npcs: []
|
||||
|
||||
- id: study
|
||||
name: Study
|
||||
description: |
|
||||
A cluttered writing study. Papers lie scattered across the desk, and a
|
||||
framed photograph has fallen face-up among them.
|
||||
music: musicbox.ogg
|
||||
exits:
|
||||
west: landing
|
||||
items: [photograph]
|
||||
npcs: []
|
||||
|
||||
- id: chapel
|
||||
name: Manor Chapel
|
||||
description: |
|
||||
A tiny private chapel. A stone shrine stands at the far end beneath a
|
||||
narrow window. A cold presence presses against you here.
|
||||
music: lament.ogg
|
||||
exits:
|
||||
east: landing
|
||||
items: [shrine]
|
||||
npcs: []
|
||||
descriptionStates:
|
||||
- when: [{ flag: ghost_banished }]
|
||||
text: |
|
||||
A tiny private chapel. The shrine stands quiet beneath the window,
|
||||
and only still moonlight remains. The cold presence is gone.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@Test
|
||||
void loadsTheTestFixture() {
|
||||
Tutorial t = new TutorialLoader().load(); // test-classpath /world/tutorial.yaml shadows prod
|
||||
void loadsTheShippedTutorial() {
|
||||
// The toy fixture is gone, so this resolves the real /world/tutorial.yaml.
|
||||
Tutorial t = new TutorialLoader().load();
|
||||
assertThat(t.isEmpty()).isFalse();
|
||||
assertThat(t.intro()).isNotBlank();
|
||||
assertThat(t.closingTips()).isNotBlank();
|
||||
assertThat(t.steps()).hasSize(2);
|
||||
assertThat(t.steps()).isNotEmpty();
|
||||
assertThat(t.steps().getFirst().expect()).isEqualTo("look");
|
||||
assertThat(t.steps().getFirst().minArgs()).isZero();
|
||||
}
|
||||
|
||||
@@ -6,30 +6,41 @@ import thb.jeanluc.adventure.model.NpcReaction;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Loads the real, shipped Haunted Manor world (the toy test fixture has been
|
||||
* removed, so {@code new WorldLoader().load()} resolves the production YAML).
|
||||
* Assertions check form-invariants of the delivered world rather than fragile
|
||||
* exact values, so adding flavour rooms/items won't break the suite — but a
|
||||
* broken start room, missing core item, or unwired exit will.
|
||||
*/
|
||||
class WorldLoaderTest {
|
||||
|
||||
@Test
|
||||
void load_happyPath_buildsWorldFromTestFixtures() {
|
||||
void load_happyPath_buildsTheShippedManor() {
|
||||
WorldLoader.LoadResult result = new WorldLoader().load();
|
||||
|
||||
assertThat(result.world().getTitle()).isEqualTo("Test Manor");
|
||||
assertThat(result.world().getRooms().keySet()).containsExactlyInAnyOrder("kitchen", "hallway");
|
||||
assertThat(result.world().getTitle()).isEqualTo("Haunted Manor");
|
||||
assertThat(result.world().getRooms()).hasSize(14);
|
||||
assertThat(result.world().getRooms().keySet())
|
||||
.contains("foyer", "grand_hall", "cellar_stairs", "boiler_room",
|
||||
"grand_staircase", "landing", "chapel");
|
||||
assertThat(result.world().getItems().keySet())
|
||||
.containsExactlyInAnyOrder("letter", "lamp", "key", "matches", "torch", "lit_torch");
|
||||
assertThat(result.world().getNpcs().keySet()).containsExactly("old_man");
|
||||
.contains("lamp", "valve_wheel", "valve", "fuse", "generator",
|
||||
"locket", "photograph", "shrine", "front_door", "lit_torch");
|
||||
assertThat(result.world().getNpcs().keySet()).contains("old_man");
|
||||
|
||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("kitchen");
|
||||
assertThat(result.player().getGold()).isEqualTo(5);
|
||||
assertThat(result.player().getCurrentRoom().getId()).isEqualTo("foyer");
|
||||
assertThat(result.player().getGold()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void load_resolvesExitsBidirectionally_whenYamlDeclaresThem() {
|
||||
var loaded = new WorldLoader().load().world();
|
||||
var kitchen = loaded.getRooms().get("kitchen");
|
||||
var hallway = loaded.getRooms().get("hallway");
|
||||
var foyer = loaded.getRooms().get("foyer");
|
||||
var grandHall = loaded.getRooms().get("grand_hall");
|
||||
|
||||
assertThat(kitchen.getExit(Direction.NORTH)).contains(hallway);
|
||||
assertThat(hallway.getExit(Direction.SOUTH)).contains(kitchen);
|
||||
assertThat(foyer.getExit(Direction.NORTH)).contains(grandHall);
|
||||
assertThat(grandHall.getExit(Direction.SOUTH)).contains(foyer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -37,9 +48,9 @@ class WorldLoaderTest {
|
||||
var loaded = new WorldLoader().load().world();
|
||||
var oldMan = loaded.getNpcs().get("old_man");
|
||||
|
||||
assertThat(oldMan.getReactions()).containsKey("lamp");
|
||||
NpcReaction r = oldMan.reactionFor("lamp").orElseThrow();
|
||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("lamp"));
|
||||
assertThat(r.getGives()).isEqualTo(loaded.getItems().get("key"));
|
||||
assertThat(oldMan.getReactions()).containsKey("letter");
|
||||
NpcReaction r = oldMan.reactionFor("letter").orElseThrow();
|
||||
assertThat(r.getConsumes()).isEqualTo(loaded.getItems().get("letter"));
|
||||
assertThat(r.getGives()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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