fix(io,save): music thread leak, save-slot collision, unresponsive GUI
- OggMusicBackend shared one 'stopping' flag across playbacks and nulled the thread handle without checking whether the join had actually succeeded. A thread that outlived its join budget saw the flag flip back to false and resumed playing, so two tracks played at once and the orphan never died. Each playback now owns its stop token, and fades abort when it is set. - SaveService.slug mapped "Bedroom Run" and "Bedroom_Run" to the same filename, so the second save silently overwrote the first. The slug is now injective: a short hash is appended whenever sanitising was lossy. Clean names keep clean filenames, so existing saves still load. - The Swing worker is the only consumer of the input queue, so when it died on a loader error the window stayed open and ignored every command forever. It now reports the error and closes. Typing while the menu overlay is up no longer queues commands that get replayed once the menu closes.
This commit is contained in:
@@ -14,6 +14,7 @@ import javax.swing.SwingUtilities;
|
||||
@Slf4j
|
||||
public final class AppGui {
|
||||
|
||||
/** Utility class; not instantiable. */
|
||||
private AppGui() {
|
||||
}
|
||||
|
||||
@@ -29,8 +30,13 @@ public final class AppGui {
|
||||
try {
|
||||
App.run(io);
|
||||
} catch (RuntimeException e) {
|
||||
// The worker is the only consumer of the input queue, so once
|
||||
// it dies nothing can service the window again. Tell the player
|
||||
// what happened and close down rather than leaving a live-looking
|
||||
// window that silently ignores every command.
|
||||
log.error("Fatal error during game startup", e);
|
||||
io.write("Fatal error: " + e.getMessage());
|
||||
io.fatal("Fatal error: " + e.getMessage()
|
||||
+ "\n\nThe game cannot continue and will close.");
|
||||
}
|
||||
}, "game-loop");
|
||||
worker.setDaemon(true);
|
||||
|
||||
@@ -7,6 +7,7 @@ import javax.sound.sampled.AudioInputStream;
|
||||
import javax.sound.sampled.AudioSystem;
|
||||
import javax.sound.sampled.FloatControl;
|
||||
import javax.sound.sampled.SourceDataLine;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.InputStream;
|
||||
|
||||
@@ -20,17 +21,46 @@ import java.io.InputStream;
|
||||
@Slf4j
|
||||
public final class OggMusicBackend implements MusicBackend {
|
||||
|
||||
/** Classpath directory the bundled tracks are looked up in. */
|
||||
private static final String MUSIC_DIR = "/music/";
|
||||
|
||||
/** Duration of a fade-in or fade-out, in milliseconds. */
|
||||
private static final int FADE_MS = 400;
|
||||
|
||||
/** Number of gain steps a fade is split into. */
|
||||
private static final int FADE_STEPS = 20;
|
||||
|
||||
/** Size of the PCM transfer buffer, in bytes. */
|
||||
private static final int BUFFER = 4096;
|
||||
|
||||
/** Gain a fade starts from and ends at, in decibels. */
|
||||
private static final float FLOOR_DB = -40f; // near-silence for fades
|
||||
|
||||
/** The currently playing thread, or null when silent. */
|
||||
private volatile Thread playThread;
|
||||
private volatile boolean stopping;
|
||||
|
||||
/**
|
||||
* Stop token of the current play thread. Each playback owns its own token
|
||||
* rather than sharing one flag: if {@link #stopCurrent()} times out waiting
|
||||
* for a thread, that thread's token stays set, so it still winds down and
|
||||
* can never be revived by the next {@link #fadeTo}.
|
||||
*/
|
||||
private volatile AtomicBoolean playStop;
|
||||
|
||||
/** Output line of the current playback, used by {@link #setGainDb}. */
|
||||
private volatile SourceDataLine line;
|
||||
|
||||
/** Gain most recently requested by the player, in decibels. */
|
||||
private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb();
|
||||
|
||||
/**
|
||||
* Stops the current track and starts {@code track} on a fresh daemon thread,
|
||||
* fading in to {@code gainDb}. A track that is not on the classpath is logged
|
||||
* and leaves playback silent.
|
||||
*
|
||||
* @param track file name below {@code /music/}
|
||||
* @param gainDb target MASTER_GAIN in decibels
|
||||
*/
|
||||
@Override
|
||||
public synchronized void fadeTo(String track, float gainDb) {
|
||||
stopCurrent();
|
||||
@@ -40,17 +70,24 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
log.warn("Music resource not found, staying silent: {}", resource);
|
||||
return;
|
||||
}
|
||||
stopping = false;
|
||||
playThread = new Thread(() -> playLoop(resource, gainDb), "music");
|
||||
AtomicBoolean stop = new AtomicBoolean(false);
|
||||
playStop = stop;
|
||||
playThread = new Thread(() -> playLoop(resource, gainDb, stop), "music");
|
||||
playThread.setDaemon(true);
|
||||
playThread.start();
|
||||
}
|
||||
|
||||
/** Fades the current track out and stops the play thread. */
|
||||
@Override
|
||||
public synchronized void fadeOut() {
|
||||
stopCurrent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a new gain to the running playback immediately, without a fade.
|
||||
*
|
||||
* @param gainDb target MASTER_GAIN in decibels
|
||||
*/
|
||||
@Override
|
||||
public synchronized void setGainDb(float gainDb) {
|
||||
targetGainDb = gainDb;
|
||||
@@ -60,30 +97,51 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stops playback and releases the audio line. */
|
||||
@Override
|
||||
public synchronized void shutdown() {
|
||||
stopCurrent();
|
||||
}
|
||||
|
||||
/** Signals the play thread to stop and waits for its fade-out + cleanup. */
|
||||
/**
|
||||
* Signals the play thread to stop and waits for its fade-out + cleanup.
|
||||
* The thread's stop token stays set even if the join times out, so a slow
|
||||
* thread still terminates on its own instead of playing on forever.
|
||||
*/
|
||||
private void stopCurrent() {
|
||||
Thread t = playThread;
|
||||
AtomicBoolean stop = playStop;
|
||||
if (t == null) {
|
||||
return;
|
||||
}
|
||||
stopping = true;
|
||||
if (stop != null) {
|
||||
stop.set(true);
|
||||
}
|
||||
try {
|
||||
t.join(FADE_MS + 600L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (t.isAlive()) {
|
||||
log.warn("Music thread did not stop within the join budget; it will wind down on its own");
|
||||
}
|
||||
playThread = null;
|
||||
playStop = null;
|
||||
}
|
||||
|
||||
private void playLoop(String resource, float gainDb) {
|
||||
/**
|
||||
* Body of the play thread: decodes the resource to PCM and writes it to the
|
||||
* output line, restarting at the end of the stream until {@code stop} is set.
|
||||
* Decoding failures are logged and end playback. Runs a fade-out on exit.
|
||||
*
|
||||
* @param resource classpath path of the OGG file
|
||||
* @param gainDb gain to fade in to
|
||||
* @param stop this playback's stop token
|
||||
*/
|
||||
private void playLoop(String resource, float gainDb, AtomicBoolean stop) {
|
||||
SourceDataLine l = null;
|
||||
try {
|
||||
while (!stopping) {
|
||||
while (!stop.get()) {
|
||||
// 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);
|
||||
@@ -97,12 +155,15 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
l = AudioSystem.getSourceDataLine(pcm);
|
||||
l.open(pcm);
|
||||
l.start();
|
||||
line = l;
|
||||
ramp(l, FLOOR_DB, gainDb); // fade in once
|
||||
// Only publish the line if this playback is still current.
|
||||
if (playStop == stop) {
|
||||
line = l;
|
||||
}
|
||||
ramp(l, FLOOR_DB, gainDb, stop); // fade in once
|
||||
}
|
||||
byte[] buf = new byte[BUFFER];
|
||||
int n;
|
||||
while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) {
|
||||
while (!stop.get() && (n = din.read(buf, 0, buf.length)) != -1) {
|
||||
l.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
@@ -113,24 +174,44 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
} finally {
|
||||
if (l != null) {
|
||||
try {
|
||||
ramp(l, currentGain(l), FLOOR_DB); // fade out
|
||||
// Fade-out must run to completion, so it gets a fresh token.
|
||||
ramp(l, currentGain(l), FLOOR_DB, new AtomicBoolean(false));
|
||||
} catch (RuntimeException ignored) {
|
||||
// best-effort fade
|
||||
}
|
||||
l.stop();
|
||||
l.close();
|
||||
}
|
||||
line = null;
|
||||
// Only clear the shared line if it still belongs to this playback.
|
||||
if (playStop == stop) {
|
||||
line = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ramp(SourceDataLine l, float fromDb, float toDb) {
|
||||
/**
|
||||
* Ramps the gain, aborting early if this playback has been stopped.
|
||||
*
|
||||
* @param l the line to ramp
|
||||
* @param fromDb starting gain in decibels
|
||||
* @param toDb final gain in decibels
|
||||
* @param stop stop token checked between steps
|
||||
*/
|
||||
private void ramp(SourceDataLine l, float fromDb, float toDb, AtomicBoolean stop) {
|
||||
for (int i = 0; i <= FADE_STEPS; i++) {
|
||||
if (stop.get()) {
|
||||
return;
|
||||
}
|
||||
applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS);
|
||||
sleep(FADE_MS / FADE_STEPS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param l the line to read
|
||||
* @return the line's current gain, or {@link #targetGainDb} if the platform
|
||||
* offers no master-gain control
|
||||
*/
|
||||
private float currentGain(SourceDataLine l) {
|
||||
try {
|
||||
return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue();
|
||||
@@ -139,6 +220,13 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the line's gain, clamped to the range it supports. Lines without a
|
||||
* master-gain control are left untouched.
|
||||
*
|
||||
* @param l the line to adjust
|
||||
* @param db requested gain in decibels
|
||||
*/
|
||||
private void applyGain(SourceDataLine l, float db) {
|
||||
try {
|
||||
FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN);
|
||||
@@ -148,6 +236,11 @@ public final class OggMusicBackend implements MusicBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleeps, restoring the interrupt flag instead of propagating.
|
||||
*
|
||||
* @param ms milliseconds to sleep
|
||||
*/
|
||||
private void sleep(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
|
||||
@@ -15,6 +15,7 @@ import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
@@ -71,20 +72,50 @@ public class SwingIO implements GameIO {
|
||||
|
||||
/** Side-panel width as a fraction of the window width, with a lower bound only. */
|
||||
private static final double SIDE_RATIO = 0.34;
|
||||
|
||||
/** Lower bound for the side-panel width, in unscaled pixels. */
|
||||
private static final int SIDE_MIN = 220;
|
||||
|
||||
/** Background music of the current room. */
|
||||
private final MusicController music = new MusicController(new OggMusicBackend());
|
||||
|
||||
/**
|
||||
* Lines typed into the input field, handed from the Swing EDT to the game
|
||||
* loop, which blocks on {@link #readLine()}.
|
||||
*/
|
||||
private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
|
||||
|
||||
/** The game window. */
|
||||
private final JFrame frame;
|
||||
|
||||
/** Centre region: the scrolling transcript of styled output. */
|
||||
private final JTextPane output;
|
||||
|
||||
/** Document behind {@link #output}; all text is appended to it. */
|
||||
private final StyledDocument doc;
|
||||
|
||||
/** Bottom region: the command line the player types into. */
|
||||
private final JTextField input;
|
||||
|
||||
/** Top region: the single-line status bar. */
|
||||
private final JLabel hud;
|
||||
|
||||
/** Right region (upper): the drawn map. */
|
||||
private final MapPanel map;
|
||||
|
||||
/** Scroll pane wrapping {@link #map}. */
|
||||
private final JScrollPane sideScroll;
|
||||
|
||||
/** Right region (lower): the quest log. */
|
||||
private final QuestPanel quests;
|
||||
|
||||
/** Scroll pane wrapping {@link #quests}. */
|
||||
private final JScrollPane questScroll;
|
||||
|
||||
/** Right region: container holding {@link #sideScroll} and {@link #questScroll}. */
|
||||
private final JPanel sidePanel;
|
||||
|
||||
/** Font all component fonts are derived from; bundled TTF or a monospaced fallback. */
|
||||
private final Font baseFont;
|
||||
|
||||
/** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */
|
||||
@@ -93,6 +124,12 @@ public class SwingIO implements GameIO {
|
||||
/** User zoom multiplier, adjusted live via keyboard. */
|
||||
private float zoom = 1f;
|
||||
|
||||
/**
|
||||
* Builds the window, wires the input field to the input queue, installs the
|
||||
* zoom keys, and shows the frame.
|
||||
*
|
||||
* @param title window title
|
||||
*/
|
||||
public SwingIO(String title) {
|
||||
baseFont = loadFont();
|
||||
uiScale = detectScale();
|
||||
@@ -156,7 +193,11 @@ public class SwingIO implements GameIO {
|
||||
input.requestFocusInWindow();
|
||||
}
|
||||
|
||||
/** Loads the bundled font if present, else falls back to a logical monospaced font. */
|
||||
/**
|
||||
* Loads the bundled font if present, else falls back to a logical monospaced font.
|
||||
*
|
||||
* @return the font all component fonts are derived from
|
||||
*/
|
||||
private Font loadFont() {
|
||||
try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) {
|
||||
if (in != null) {
|
||||
@@ -170,7 +211,11 @@ public class SwingIO implements GameIO {
|
||||
return new Font(Font.MONOSPACED, Font.PLAIN, 12);
|
||||
}
|
||||
|
||||
/** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */
|
||||
/**
|
||||
* Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI.
|
||||
*
|
||||
* @return the display scale, at least 1.0
|
||||
*/
|
||||
private float detectScale() {
|
||||
try {
|
||||
GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||
@@ -207,6 +252,8 @@ public class SwingIO implements GameIO {
|
||||
* Auto-scale derived from the window size relative to the design size
|
||||
* (900x600). At the initial size this equals {@link #uiScale}; growing the
|
||||
* window grows the text, mirroring how the map fits its panel.
|
||||
*
|
||||
* @return the auto-scale, clamped to 0.6–2.6
|
||||
*/
|
||||
private float frameScale() {
|
||||
double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0);
|
||||
@@ -242,10 +289,23 @@ public class SwingIO implements GameIO {
|
||||
root.getActionMap().put("zoomReset", action(e -> setZoom(1f)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a key stroke to an action name on the component's window-scoped input map.
|
||||
*
|
||||
* @param c the component whose input map is extended
|
||||
* @param ks the key stroke to bind
|
||||
* @param name the action-map key it triggers
|
||||
*/
|
||||
private void bind(JComponent c, KeyStroke ks, String name) {
|
||||
c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts a lambda to a Swing action.
|
||||
*
|
||||
* @param body code run when the action fires
|
||||
* @return the action
|
||||
*/
|
||||
private AbstractAction action(java.util.function.Consumer<ActionEvent> body) {
|
||||
return new AbstractAction() {
|
||||
@Override
|
||||
@@ -255,11 +315,20 @@ public class SwingIO implements GameIO {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the zoom, clamped to 0.6–3.0, and re-applies the fonts.
|
||||
*
|
||||
* @param z the requested zoom multiplier
|
||||
*/
|
||||
private void setZoom(float z) {
|
||||
zoom = Math.max(0.6f, Math.min(3.0f, z));
|
||||
applyFonts();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param s the semantic role
|
||||
* @return the colour this frontend paints that role in
|
||||
*/
|
||||
private Color colorFor(Style s) {
|
||||
return switch (s) {
|
||||
case HEADING -> new Color(0xc9, 0x8a, 0xe0);
|
||||
@@ -272,6 +341,12 @@ public class SwingIO implements GameIO {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the spans to the output pane and scrolls to the end. Runs on the
|
||||
* Swing EDT; safe to call from the game loop.
|
||||
*
|
||||
* @param spans the styled runs to append
|
||||
*/
|
||||
private void appendSpans(List<Span> spans) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
try {
|
||||
@@ -357,6 +432,23 @@ public class SwingIO implements GameIO {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports an unrecoverable error and closes the GUI. The game loop runs on
|
||||
* the only thread that consumes player input, so if it dies the window would
|
||||
* otherwise stay open and silently swallow every command. Shows a modal
|
||||
* dialog, then exits.
|
||||
*
|
||||
* @param message text shown to the player
|
||||
*/
|
||||
public void fatal(String message) {
|
||||
music.shutdown();
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
JOptionPane.showMessageDialog(frame, message, "Haunted Manor", JOptionPane.ERROR_MESSAGE);
|
||||
frame.dispose();
|
||||
System.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMap(MapView view) {
|
||||
map.show(view);
|
||||
@@ -405,7 +497,11 @@ public class SwingIO implements GameIO {
|
||||
|
||||
/** Current menu being shown, so a window resize can rebuild it at the new scale. */
|
||||
private String menuTitle;
|
||||
|
||||
/** Options of the menu currently shown, or null when no menu is visible. */
|
||||
private List<String> menuOptions;
|
||||
|
||||
/** Queue the current menu's chosen index is offered to. */
|
||||
private LinkedBlockingQueue<Integer> menuPicked;
|
||||
|
||||
/** Rebuilds the visible menu overlay at the current window scale (called on resize). */
|
||||
@@ -415,7 +511,15 @@ public class SwingIO implements GameIO {
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs the menu as the frame's glass pane (covers everything) and shows it. */
|
||||
/**
|
||||
* Installs the menu as the frame's glass pane (covers everything) and shows it.
|
||||
* Disables the input field while the menu is up. A button click offers that
|
||||
* button's index to {@code picked}; Escape offers the last option.
|
||||
*
|
||||
* @param title heading shown above the options
|
||||
* @param options option labels, one button each
|
||||
* @param picked queue the chosen index is offered to
|
||||
*/
|
||||
private void showMenuOverlay(String title, List<String> options, LinkedBlockingQueue<Integer> picked) {
|
||||
menuTitle = title;
|
||||
menuOptions = options;
|
||||
@@ -463,24 +567,55 @@ public class SwingIO implements GameIO {
|
||||
glass.add(card); // single child → centered by GridBagLayout
|
||||
frame.setGlassPane(glass);
|
||||
glass.setVisible(true);
|
||||
// The glass pane swallows mouse events but cannot take keyboard focus,
|
||||
// so the input field would stay live behind the menu and queue commands
|
||||
// that get replayed once the menu closes. Disable it for the duration.
|
||||
input.setEnabled(false);
|
||||
glass.requestFocusInWindow();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the menu overlay, discards anything typed while it was up, and
|
||||
* returns focus to the input field.
|
||||
*/
|
||||
private void hideMenuOverlay() {
|
||||
menuOptions = null; // stop resize rebuilds once the menu is dismissed
|
||||
frame.getGlassPane().setVisible(false);
|
||||
// Drop anything typed before the field was disabled, so a stale line
|
||||
// cannot be replayed as a game command.
|
||||
inputs.clear();
|
||||
input.setEnabled(true);
|
||||
input.requestFocusInWindow();
|
||||
}
|
||||
|
||||
/** Palette for the themed menu overlay, matching the game window's dark look. */
|
||||
private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13);
|
||||
|
||||
/** Colour of the menu heading. */
|
||||
private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0);
|
||||
|
||||
/** Menu-button background at rest. */
|
||||
private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24);
|
||||
|
||||
/** Menu-button background under the mouse. */
|
||||
private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37);
|
||||
|
||||
/** Menu-button label colour. */
|
||||
private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0);
|
||||
|
||||
/** Menu-button outline at rest. */
|
||||
private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a);
|
||||
|
||||
/** Menu-button outline under the mouse. */
|
||||
private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73);
|
||||
|
||||
/** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */
|
||||
/**
|
||||
* A flat, dark, fixed-width menu button matching the game palette, with hover feedback.
|
||||
*
|
||||
* @param text the button label
|
||||
* @param scale the scale its size and font are derived from
|
||||
* @return the button
|
||||
*/
|
||||
private JButton menuButton(String text, float scale) {
|
||||
int padV = (int) (11 * scale);
|
||||
int padH = (int) (18 * scale);
|
||||
@@ -514,12 +649,25 @@ public class SwingIO implements GameIO {
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param line outline colour
|
||||
* @param padV vertical inner padding, in pixels
|
||||
* @param padH horizontal inner padding, in pixels
|
||||
* @return a one-pixel outline with the given inner padding
|
||||
*/
|
||||
private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) {
|
||||
return BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(line, 1),
|
||||
BorderFactory.createEmptyBorder(padV, padH, padV, padH));
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the entries as comma-separated spans of the given style.
|
||||
*
|
||||
* @param spans target list the spans are added to
|
||||
* @param xs the entries
|
||||
* @param st style applied to each entry
|
||||
*/
|
||||
private void addList(List<Span> spans, List<String> xs, Style st) {
|
||||
for (int i = 0; i < xs.size(); i++) {
|
||||
if (i > 0) {
|
||||
|
||||
@@ -23,9 +23,13 @@ import java.util.stream.Stream;
|
||||
@Slf4j
|
||||
public class SaveService {
|
||||
|
||||
/** Name of the settings file, which shares the directory but is not a save slot. */
|
||||
private static final String SETTINGS_FILENAME = "settings.json";
|
||||
|
||||
/** Directory holding one JSON file per slot. */
|
||||
private final Path dir;
|
||||
|
||||
/** Jackson mapper used for both directions. */
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
/** Uses the default {@code ./saves} directory. */
|
||||
@@ -33,21 +37,47 @@ public class SaveService {
|
||||
this(Path.of("saves"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses a custom save directory; it is created on the first write.
|
||||
*
|
||||
* @param dir directory the slot files are read from and written to
|
||||
*/
|
||||
public SaveService(Path dir) {
|
||||
this.dir = dir;
|
||||
}
|
||||
|
||||
/** Slug used for the on-disk filename of a slot name. */
|
||||
/**
|
||||
* Slug used for the on-disk filename of a slot name.
|
||||
*
|
||||
* <p>The mapping must be injective: two different slot names may never
|
||||
* produce the same filename, or one save would silently overwrite the
|
||||
* other. Sanitising is lossy ({@code "Bedroom Run"} and {@code "Bedroom_Run"}
|
||||
* both reduce to {@code bedroom_run}), so whenever a character actually had
|
||||
* to be replaced, a short hash of the original name is appended. Names that
|
||||
* survive sanitising unchanged keep their clean filename.
|
||||
*
|
||||
* @param slotName the player-visible save name
|
||||
* @return a filesystem-safe, collision-free slug
|
||||
*/
|
||||
public static String slug(String slotName) {
|
||||
String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_");
|
||||
String raw = slotName.trim().toLowerCase(Locale.ROOT);
|
||||
String s = raw.replaceAll("[^a-z0-9_-]+", "_");
|
||||
if (s.isBlank()) {
|
||||
return "save";
|
||||
}
|
||||
if (!s.equals(raw)) {
|
||||
s = s + "_" + Integer.toHexString(raw.hashCode() & 0xffff);
|
||||
}
|
||||
// Never let a user-named save collide with the settings file.
|
||||
return s.equals("settings") ? "settings_" : s;
|
||||
}
|
||||
|
||||
/** Writes the session to {@code <slug>.json} atomically. */
|
||||
/**
|
||||
* Writes the session to {@code <slug>.json} atomically.
|
||||
*
|
||||
* @param session the session to persist; its slot name determines the filename
|
||||
* @throws SaveException if the file could not be written
|
||||
*/
|
||||
public void save(GameSession session) {
|
||||
SaveData data = SaveCodec.capture(session);
|
||||
try {
|
||||
@@ -70,7 +100,11 @@ public class SaveService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists all readable slots, newest first; skips unreadable files. */
|
||||
/**
|
||||
* Lists all readable slots, newest first; skips unreadable files.
|
||||
*
|
||||
* @return slot metadata sorted by save time, newest first; empty if the directory does not exist
|
||||
*/
|
||||
public List<SaveSlotInfo> list() {
|
||||
if (!Files.isDirectory(dir)) {
|
||||
return List.of();
|
||||
@@ -97,7 +131,13 @@ public class SaveService {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Loads a slot by slug: read JSON, reload world, overlay snapshot. */
|
||||
/**
|
||||
* Loads a slot by slug: read JSON, reload world, overlay snapshot.
|
||||
*
|
||||
* @param slug filename stem of the slot, as reported by {@link SaveSlotInfo#slug()}
|
||||
* @return a session with the world freshly loaded and the snapshot applied
|
||||
* @throws SaveException if the file is unreadable or its schema version is not supported
|
||||
*/
|
||||
public GameSession load(String slug) {
|
||||
Path file = dir.resolve(slug + ".json");
|
||||
SaveData data;
|
||||
|
||||
@@ -45,10 +45,33 @@ class SaveServiceTest {
|
||||
List<SaveSlotInfo> slots = svc.list();
|
||||
assertThat(slots).hasSize(1);
|
||||
assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run");
|
||||
assertThat(slots.getFirst().slug()).isEqualTo("alpha_run");
|
||||
// Sanitising the space is lossy, so the slug carries a disambiguating hash.
|
||||
assertThat(slots.getFirst().slug()).startsWith("alpha_run_");
|
||||
assertThat(slots.getFirst().turn()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void slug_forNameNeedingNoSanitising_staysClean() {
|
||||
assertThat(SaveService.slug("Manor")).isEqualTo("manor");
|
||||
}
|
||||
|
||||
@Test
|
||||
void slug_forNamesDifferingOnlyInSeparators_doesNotCollide() {
|
||||
// "Bedroom Run" and "Bedroom_Run" both sanitise to "bedroom_run"; without
|
||||
// disambiguation the second save would silently overwrite the first.
|
||||
assertThat(SaveService.slug("Bedroom Run"))
|
||||
.isNotEqualTo(SaveService.slug("Bedroom_Run"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void save_withNamesDifferingOnlyInSeparators_keepsBothSlots(@TempDir Path dir) {
|
||||
SaveService svc = new SaveService(dir);
|
||||
svc.save(freshSession("Bedroom Run"));
|
||||
svc.save(freshSession("Bedroom_Run"));
|
||||
|
||||
assertThat(svc.list()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSkipsCorruptFiles(@TempDir Path dir) throws Exception {
|
||||
Files.writeString(dir.resolve("broken.json"), "{ not json");
|
||||
|
||||
Reference in New Issue
Block a user