Merge GUI background music into develop

Per-room OGG Vorbis background music in the Swing GUI: a testable MusicController
(idempotent track switch, null->silence, OFF disables) over an isolated
OggMusicBackend (vorbisspi/jorbis, streamed PCM, ~400ms gain fades, graceful on
missing files). Driven from GameIO.setMusic per turn (console no-op) and a new
persisted Music level (Off/Low/Med/High, default Medium) in Settings. Tracks come
from an external gitignored music/ folder via an optional rooms.yaml `music:`
field. Console stays silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:29:11 +02:00
24 changed files with 574 additions and 31 deletions

View File

@@ -43,3 +43,6 @@ build/
# Local save games and settings
saves/
# External background-music files (not bundled)
music/

View File

@@ -180,6 +180,14 @@ vorerst puzzle-fokussiert ohne Survival-Druck.
### 8. Musik (GUI)
> ✅ umgesetzt (Branch `feature/music`): OGG Vorbis (vorbisspi/jorbis), pro Raum
> via `music:`-Feld in `rooms.yaml`, externer gitignored `music/`-Ordner (nicht im
> JAR). Idempotenter Track-Wechsel mit ~400 ms Fades; Räume ohne `music` blenden zu
> Stille; gleicher Track läuft nahtlos weiter. Music-Stufe Off/Low/Med/High in den
> Settings (Default Medium), persistiert. Konsole stumm. Testbarer `MusicController`
> über isoliertes `OggMusicBackend`; fehlende Datei → still, kein Absturz. Siehe
> `docs/music.md`.
- Hintergrundmusik **nur in der GUI**.
- **Pro Raum definierbar via YAML**: in `rooms.yaml` ein optionales Feld
(z.B. `music: <dateiname>`), Dateien in *einem* Ordner.

View File

@@ -0,0 +1,17 @@
# Background music (GUI)
Background music plays only in the Swing GUI. It is **data-driven and external**:
- 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.
- 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);
a room with no `music:` field fades to silence; the same track keeps playing
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.
- The console version is always silent.
Decoding uses the `vorbisspi` + `jorbis` libraries via the `javax.sound` SPI.

View File

@@ -49,6 +49,18 @@
<version>${logback.version}</version>
</dependency>
<!-- OGG Vorbis decoding (GUI background music) -->
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>vorbisspi</artifactId>
<version>1.0.3.3</version>
</dependency>
<dependency>
<groupId>com.googlecode.soundlibs</groupId>
<artifactId>jorbis</artifactId>
<version>0.0.17.4</version>
</dependency>
<!-- Tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>

View File

@@ -67,8 +67,7 @@ public final class App {
SaveService saves = new SaveService();
SettingsStore settingsStore = new SettingsStore();
Settings settings = settingsStore.load();
ConsoleIO consoleIo = io instanceof ConsoleIO c ? c : null;
SettingsMenu.apply(settings, consoleIo);
SettingsMenu.apply(settings, io);
boolean running = true;
while (running) {
@@ -84,7 +83,7 @@ public final class App {
}
}
}
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore, consoleIo);
case SETTINGS -> settings = SettingsMenu.show(io, settings, settingsStore);
case QUIT -> running = false;
}
}
@@ -143,5 +142,6 @@ public final class App {
new LookCommand().execute(ctx, List.of());
game.run();
io.setMusic(null); // fade music out when returning to the menu
}
}

View File

@@ -109,6 +109,7 @@ public class Game {
ctx.getPlayer().getCurrentRoom());
ctx.getIo().setMap(map);
ctx.getIo().setQuests(QuestEngine.viewOf(ctx));
ctx.getIo().setMusic(ctx.getPlayer().getCurrentRoom().getMusic());
}
/**

View File

@@ -65,6 +65,16 @@ public interface GameIO {
// no-op
}
/** Per-turn current-room music track; null/blank = silence. Console no-op; GUI plays. */
default void setMusic(String track) {
// no audio in text mode
}
/** Applies the music volume/level. Console no-op; GUI sets it. */
default void setMusicLevel(MusicLevel level) {
// no audio in text mode
}
/** On-demand quest log (the 'quests' command). Default renders styled text. */
default void showQuests(QuestView view) {
print(QuestText.render(view));

View File

@@ -0,0 +1,17 @@
package thb.jeanluc.adventure.io;
/** Raw audio operations — the only part that touches sound hardware. */
public interface MusicBackend {
/** Fade the current track out, then start {@code track} (looping) and fade in to {@code gainDb}. */
void fadeTo(String track, float gainDb);
/** Fade the current track out and stop. */
void fadeOut();
/** Set the playback volume immediately. */
void setGainDb(float gainDb);
/** Stop playback and release resources. */
void shutdown();
}

View File

@@ -0,0 +1,55 @@
package thb.jeanluc.adventure.io;
import java.util.Objects;
/**
* Frontend-agnostic music decision logic. Idempotent per track (never restarts
* the same track), fades to silence on a null/blank track, and honours the
* {@link MusicLevel} (OFF disables playback). Delegates raw operations to a
* {@link MusicBackend}.
*/
public final class MusicController {
private final MusicBackend backend;
private MusicLevel level = MusicLevel.MEDIUM;
private String current; // currently selected track, or null = silence
public MusicController(MusicBackend backend) {
this.backend = backend;
}
/** Called per turn with the current room's music field (may be null/blank). */
public void room(String track) {
if (!level.isOn()) {
return;
}
String next = (track == null || track.isBlank()) ? null : track;
if (Objects.equals(next, current)) {
return;
}
current = next;
if (next == null) {
backend.fadeOut();
} else {
backend.fadeTo(next, level.gainDb());
}
}
/** Applies a new music level (volume / off). */
public void level(MusicLevel newLevel) {
if (newLevel == this.level) {
return;
}
this.level = newLevel;
if (!newLevel.isOn()) {
backend.fadeOut();
current = null; // turning back on replays on the next room() call
} else {
backend.setGainDb(newLevel.gainDb());
}
}
public void shutdown() {
backend.shutdown();
}
}

View File

@@ -0,0 +1,26 @@
package thb.jeanluc.adventure.io;
/** Background-music volume level, cycled in the Settings menu. */
public enum MusicLevel {
OFF, LOW, MEDIUM, HIGH;
/** Target MASTER_GAIN in decibels. Irrelevant for {@link #OFF} (playback disabled). */
public float gainDb() {
return switch (this) {
case OFF -> Float.NEGATIVE_INFINITY;
case LOW -> -20f;
case MEDIUM -> -10f;
case HIGH -> 0f;
};
}
/** @return true when music should play. */
public boolean isOn() {
return this != OFF;
}
/** Next level in the cycle (wraps HIGH → OFF). */
public MusicLevel next() {
return values()[(ordinal() + 1) % values().length];
}
}

View File

@@ -0,0 +1,155 @@
package thb.jeanluc.adventure.io;
import lombok.extern.slf4j.Slf4j;
import javax.sound.sampled.AudioFormat;
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;
/**
* 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.
*/
@Slf4j
public final class OggMusicBackend implements MusicBackend {
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;
private static final float FLOOR_DB = -40f; // near-silence for fades
private volatile Thread playThread;
private volatile boolean stopping;
private volatile SourceDataLine line;
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
@Override
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);
return;
}
stopping = false;
playThread = new Thread(() -> playLoop(file, gainDb), "music");
playThread.setDaemon(true);
playThread.start();
}
@Override
public synchronized void fadeOut() {
stopCurrent();
}
@Override
public synchronized void setGainDb(float gainDb) {
targetGainDb = gainDb;
SourceDataLine l = line;
if (l != null) {
applyGain(l, gainDb);
}
}
@Override
public synchronized void shutdown() {
stopCurrent();
}
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
private void stopCurrent() {
Thread t = playThread;
if (t == null) {
return;
}
stopping = true;
try {
t.join(FADE_MS + 600L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
playThread = null;
}
private void playLoop(Path file, float gainDb) {
SourceDataLine l = null;
try {
while (!stopping) {
try (AudioInputStream in = AudioSystem.getAudioInputStream(file.toFile())) {
AudioFormat base = in.getFormat();
AudioFormat pcm = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED,
base.getSampleRate(), 16, base.getChannels(),
base.getChannels() * 2, base.getSampleRate(), false);
try (AudioInputStream din = AudioSystem.getAudioInputStream(pcm, in)) {
if (l == null) {
l = AudioSystem.getSourceDataLine(pcm);
l.open(pcm);
l.start();
line = l;
ramp(l, FLOOR_DB, gainDb); // fade in once
}
byte[] buf = new byte[BUFFER];
int n;
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
l.write(buf, 0, n);
}
}
}
}
} catch (Exception e) {
log.warn("Music playback failed for {}: {}", file, e.getMessage());
} finally {
if (l != null) {
try {
ramp(l, currentGain(l), FLOOR_DB); // fade out
} catch (RuntimeException ignored) {
// best-effort fade
}
l.stop();
l.close();
}
line = null;
}
}
private void ramp(SourceDataLine l, float fromDb, float toDb) {
for (int i = 0; i <= FADE_STEPS; i++) {
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
sleep(FADE_MS / FADE_STEPS);
}
}
private float currentGain(SourceDataLine l) {
try {
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
} catch (IllegalArgumentException e) {
return targetGainDb;
}
}
private void applyGain(SourceDataLine l, float db) {
try {
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
gain.setValue(Math.max(gain.getMinimum(), Math.min(gain.getMaximum(), db)));
} catch (IllegalArgumentException ignored) {
// line has no master-gain control on this platform
}
}
private void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View File

@@ -74,6 +74,7 @@ public class SwingIO implements GameIO {
private static final int SIDE_MIN = 180;
private static final int SIDE_MAX = 460;
private final MusicController music = new MusicController(new OggMusicBackend());
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
private final JFrame frame;
private final JTextPane output;
@@ -333,6 +334,16 @@ public class SwingIO implements GameIO {
SwingUtilities.invokeLater(() -> hud.setText(txt));
}
@Override
public void setMusic(String track) {
music.room(track);
}
@Override
public void setMusicLevel(MusicLevel level) {
music.level(level);
}
@Override
public void setMap(MapView view) {
map.show(view);

View File

@@ -21,6 +21,7 @@ public final class RoomFactory {
public static Room shellFromDto(RoomDto dto) {
Room room = new Room(dto.id(), dto.name(), dto.description());
room.setDark(Boolean.TRUE.equals(dto.dark()));
room.setMusic(dto.music());
return room;
}
}

View File

@@ -15,6 +15,7 @@ import java.util.Map;
* @param exitLocks optional condition-gates per exit direction
* @param descriptionStates optional condition-gated description variants
* @param dark whether this room is dark; nullable
* @param music optional GUI background-music track filename; null = none
*/
public record RoomDto(
String id,
@@ -25,11 +26,12 @@ public record RoomDto(
List<String> npcs,
List<ExitLockDto> exitLocks,
List<DescriptionStateDto> descriptionStates,
Boolean dark
Boolean dark,
String music
) {
/** Backward-compatible constructor without the optional state fields. */
public RoomDto(String id, String name, String description,
Map<String, String> exits, List<String> items, List<String> npcs) {
this(id, name, description, exits, items, npcs, null, null, null);
this(id, name, description, exits, items, npcs, null, null, null, null);
}
}

View File

@@ -7,45 +7,41 @@ import thb.jeanluc.adventure.save.SettingsStore;
import java.util.List;
/** Minimal settings screen: toggle colour and glyph mode; persists + applies. */
/** Settings screen: cycle colour, glyph, and music level; persists + applies. */
public final class SettingsMenu {
private SettingsMenu() {
}
/**
* Loops the settings screen until the player picks "Back", persisting and
* applying each change. Returns the final settings.
*
* @param io active IO
* @param current starting settings
* @param store where to persist
* @param consoleIo the console IO to apply changes to live, or null if not console
*/
public static Settings show(GameIO io, Settings current, SettingsStore store, ConsoleIO consoleIo) {
/** Loops the settings screen until "Back", persisting + applying each change. */
public static Settings show(GameIO io, Settings current, SettingsStore store) {
Settings s = current;
while (true) {
int i = io.choose("SETTINGS", List.of(
"Colour: " + s.colorMode(),
"Glyphs: " + s.glyphMode(),
"Music: " + s.musicLevel(),
"Back"));
if (i == 0) {
s = new Settings(nextColor(s.colorMode()), s.glyphMode());
s = new Settings(nextColor(s.colorMode()), s.glyphMode(), s.musicLevel());
} else if (i == 1) {
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()));
s = new Settings(s.colorMode(), nextGlyph(s.glyphMode()), s.musicLevel());
} else if (i == 2) {
s = new Settings(s.colorMode(), s.glyphMode(), s.musicLevel().next());
} else {
return s;
}
store.save(s);
apply(s, consoleIo);
apply(s, io);
}
}
/** Applies colour/glyph settings to the console IO (no-op if null). */
public static void apply(Settings s, ConsoleIO consoleIo) {
if (consoleIo != null) {
consoleIo.setColorMode(s.colorMode());
consoleIo.setGlyphMode(s.glyphMode());
/** Applies settings to the IO: music level always; colour/glyph only on the console. */
public static void apply(Settings s, GameIO io) {
io.setMusicLevel(s.musicLevel());
if (io instanceof ConsoleIO c) {
c.setColorMode(s.colorMode());
c.setGlyphMode(s.glyphMode());
}
}

View File

@@ -55,6 +55,10 @@ public class Room {
@Setter
private boolean dark;
/** Optional GUI background-music track filename for this room; null = none. */
@Setter
private String music;
/**
* Connects this room to another in the given direction. Does not
* create the reverse connection — callers must set that up

View File

@@ -1,11 +1,29 @@
package thb.jeanluc.adventure.save;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
/** Persisted user preferences (minimal: colour + glyph fidelity). */
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
/** Persisted user preferences (colour + glyph fidelity + music level). */
public record Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode, MusicLevel musicLevel) {
public Settings {
if (colorMode == null) {
colorMode = ConsoleIO.ColorMode.AUTO;
}
if (glyphMode == null) {
glyphMode = ConsoleIO.GlyphMode.UNICODE;
}
if (musicLevel == null) {
musicLevel = MusicLevel.MEDIUM; // backward-compat: old saves lack this field
}
}
/** Backward-compatible constructor for callers that don't set music. */
public Settings(ConsoleIO.ColorMode colorMode, ConsoleIO.GlyphMode glyphMode) {
this(colorMode, glyphMode, MusicLevel.MEDIUM);
}
public static Settings defaults() {
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE);
return new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.MEDIUM);
}
}

View File

@@ -3,6 +3,7 @@
description: |
A dusty kitchen. Cobwebs hang from the rafters
and a letter lies on the table.
music: manor-theme.ogg
exits:
north: hallway
east: cellar
@@ -55,6 +56,7 @@
name: Dungeon
description: |
A cramped stone room, black as pitch. A rusty generator squats in the corner.
music: dungeon-drone.ogg
dark: true
exits:
north: kitchen

View File

@@ -0,0 +1,106 @@
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class MusicControllerTest {
/** Records backend calls for assertions. */
private static final class FakeBackend implements MusicBackend {
final List<String> calls = new ArrayList<>();
float lastGain = Float.NaN;
public void fadeTo(String track, float gainDb) { calls.add("fadeTo:" + track); lastGain = gainDb; }
public void fadeOut() { calls.add("fadeOut"); }
public void setGainDb(float gainDb) { calls.add("setGain:" + gainDb); }
public void shutdown() { calls.add("shutdown"); }
}
@Test
void firstRoomWithTrackPlaysAtDefaultLevel() {
FakeBackend b = new FakeBackend();
new MusicController(b).room("a");
assertThat(b.calls).containsExactly("fadeTo:a");
}
@Test
void sameTrackDoesNotRestart() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
b.calls.clear();
c.room("a");
assertThat(b.calls).isEmpty();
}
@Test
void differentTrackSwitches() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.room("b");
assertThat(b.calls).containsExactly("fadeTo:a", "fadeTo:b");
}
@Test
void nullOrBlankRoomFadesToSilence() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.room(null);
c.room(" ");
assertThat(b.calls).containsExactly("fadeTo:a", "fadeOut");
}
@Test
void offStopsAndIgnoresRooms() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.level(MusicLevel.OFF);
b.calls.clear();
c.room("b");
assertThat(b.calls).isEmpty();
}
@Test
void levelChangeSetsGain() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b); // default MEDIUM
c.level(MusicLevel.HIGH);
assertThat(b.calls).containsExactly("setGain:" + MusicLevel.HIGH.gainDb());
}
@Test
void turningOffThenOnReplaysOnNextRoom() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
c.level(MusicLevel.OFF); // fadeOut
c.level(MusicLevel.MEDIUM); // setGain
b.calls.clear();
c.room("a"); // current was reset → plays again
assertThat(b.calls).containsExactly("fadeTo:a");
}
@Test
void forwardsTheCurrentLevelGain() {
FakeBackend b = new FakeBackend();
MusicController c = new MusicController(b);
c.room("a");
assertThat(b.lastGain).isEqualTo(MusicLevel.MEDIUM.gainDb());
c.level(MusicLevel.HIGH);
c.room("b");
assertThat(b.lastGain).isEqualTo(MusicLevel.HIGH.gainDb());
}
@Test
void shutdownDelegatesToBackend() {
FakeBackend b = new FakeBackend();
new MusicController(b).shutdown();
assertThat(b.calls).containsExactly("shutdown");
}
}

View File

@@ -0,0 +1,30 @@
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MusicLevelTest {
@Test
void gainIncreasesWithLevel() {
assertThat(MusicLevel.LOW.gainDb()).isLessThan(MusicLevel.MEDIUM.gainDb());
assertThat(MusicLevel.MEDIUM.gainDb()).isLessThan(MusicLevel.HIGH.gainDb());
}
@Test
void isOnOnlyWhenNotOff() {
assertThat(MusicLevel.OFF.isOn()).isFalse();
assertThat(MusicLevel.LOW.isOn()).isTrue();
assertThat(MusicLevel.MEDIUM.isOn()).isTrue();
assertThat(MusicLevel.HIGH.isOn()).isTrue();
}
@Test
void nextCyclesThroughAllAndWraps() {
assertThat(MusicLevel.OFF.next()).isEqualTo(MusicLevel.LOW);
assertThat(MusicLevel.LOW.next()).isEqualTo(MusicLevel.MEDIUM);
assertThat(MusicLevel.MEDIUM.next()).isEqualTo(MusicLevel.HIGH);
assertThat(MusicLevel.HIGH.next()).isEqualTo(MusicLevel.OFF);
}
}

View File

@@ -0,0 +1,17 @@
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MusicSeamDefaultTest {
@Test
void defaultMusicSeamsAreSilentNoOps() {
TestIO io = new TestIO();
io.setMusic("anything.ogg");
io.setMusic(null);
io.setMusicLevel(MusicLevel.HIGH);
assertThat(io.outputs()).isEmpty();
}
}

View File

@@ -0,0 +1,28 @@
package thb.jeanluc.adventure.loader;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.loader.dto.RoomDto;
import thb.jeanluc.adventure.model.Room;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class RoomMusicTest {
@Test
void musicFieldIsCarriedOntoRoom() {
RoomDto dto = new RoomDto("r", "Room", "d",
Map.of(), List.of(), List.of(), null, null, null, "theme.ogg");
Room room = RoomFactory.shellFromDto(dto);
assertThat(room.getMusic()).isEqualTo("theme.ogg");
}
@Test
void absentMusicIsNull() {
RoomDto dto = new RoomDto("r", "Room", "d", Map.of(), List.of(), List.of());
Room room = RoomFactory.shellFromDto(dto);
assertThat(room.getMusic()).isNull();
}
}

View File

@@ -10,6 +10,7 @@ import thb.jeanluc.adventure.save.SaveSlotInfo;
import thb.jeanluc.adventure.save.Settings;
import thb.jeanluc.adventure.save.SettingsStore;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
import java.nio.file.Path;
@@ -64,11 +65,18 @@ class MenuTest {
@Test
void settingsMenuTogglesGlyphAndPersists(@TempDir Path dir) {
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
// choose "Glyph mode" (assume option 2) → its toggle, then "Back" (last)
TestIO io = new TestIO().enqueue("2").enqueue("3");
Settings result = SettingsMenu.show(io, Settings.defaults(), store, new ConsoleIO());
// UNICODE default toggles to ASCII; persisted
TestIO io = new TestIO().enqueue("2").enqueue("4"); // toggle Glyphs, then Back
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
assertThat(result.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
assertThat(store.load().glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
}
@Test
void settingsMenuCyclesMusicLevelAndPersists(@TempDir Path dir) {
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
TestIO io = new TestIO().enqueue("3").enqueue("4"); // cycle Music once, then Back
Settings result = SettingsMenu.show(io, Settings.defaults(), store);
assertThat(result.musicLevel()).isEqualTo(MusicLevel.MEDIUM.next()); // HIGH
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.MEDIUM.next());
}
}

View File

@@ -3,6 +3,7 @@ package thb.jeanluc.adventure.save;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.MusicLevel;
import java.io.IOException;
import java.nio.file.Files;
@@ -19,6 +20,7 @@ class SettingsStoreTest {
Settings loaded = store.load();
assertThat(loaded.colorMode()).isEqualTo(ConsoleIO.ColorMode.ON);
assertThat(loaded.glyphMode()).isEqualTo(ConsoleIO.GlyphMode.ASCII);
assertThat(loaded.musicLevel()).isEqualTo(MusicLevel.MEDIUM);
}
@Test
@@ -34,4 +36,18 @@ class SettingsStoreTest {
Settings loaded = new SettingsStore(file).load();
assertThat(loaded).isEqualTo(Settings.defaults());
}
@Test
void musicLevelRoundTrips(@TempDir Path dir) {
SettingsStore store = new SettingsStore(dir.resolve("settings.json"));
store.save(new Settings(ConsoleIO.ColorMode.AUTO, ConsoleIO.GlyphMode.UNICODE, MusicLevel.HIGH));
assertThat(store.load().musicLevel()).isEqualTo(MusicLevel.HIGH);
}
@Test
void oldSettingsWithoutMusicDefaultToMedium(@TempDir Path dir) throws Exception {
Path file = dir.resolve("settings.json");
Files.writeString(file, "{\"colorMode\":\"ON\",\"glyphMode\":\"ASCII\"}");
assertThat(new SettingsStore(file).load().musicLevel()).isEqualTo(MusicLevel.MEDIUM);
}
}