feat(commands): manage height bounds with tab completion
Release / release (push) Successful in 2m11s
CI / build (push) Successful in 1m32s

This commit is contained in:
dmg
2026-09-06 19:53:19 -04:00
parent fd601961eb
commit e802319f45
15 changed files with 484 additions and 10 deletions
+16
View File
@@ -64,6 +64,22 @@ launcher:
Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`. Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`.
## Commands
Operators (or senders granted `spigotheights.admin`) can use:
```text
/heights settings
/heights set min 0.2
/heights set max 3.0
```
Console is supported. Tab completion suggests subcommands, bound names, and valid example values; other valid numbers may also be entered.
Changes are saved to `plugins/SpigotHeights/config.yml` and take effect without a restart. Minimum must be at least `0.0625`, maximum at most `16.0`, minimum cannot exceed maximum, and the launcher threshold must remain within the range. Invalid changes or save failures leave active settings unchanged.
Players are not immediately resized. Subsequent potion use, joins, and respawns use the new limits; Restoration still returns and preserves exact scale `1.0`. These commands only change min/max; edit other settings in the configuration file and restart the server.
## Build ## Build
```bash ```bash
+12
View File
@@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting Spigot Heights
# Spigot Heights Design Log # Spigot Heights Design Log
## 2026-09-06T23:51:22Z — Settings commands completed
- Added `/heights settings` and `/heights set min|max <value>` with `spigotheights.admin` (default op), generic sender support, and prefix-filtered tab completion of valid example values.
- Validated changes are persisted through temporary-file replacement (atomic where supported) before activating shared live settings. Unrelated YAML values are preserved; malformed files and write failures do not activate new limits.
- Updated gameplay listeners to read current settings without immediately resizing players or resetting launcher cooldowns.
- Verified `./gradlew clean check jar`: all 26 tests passed. Inspected command registration and event wiring; live-server/client verification remains unperformed. Completed US-007 and the related US-004 change.
## 2026-09-06T23:45:28Z — Settings commands approved and started
- Approved [US-007](user-stories/us-007-manage-settings-commands.md): permission-protected min/max commands, tab completion, validation, and persistent live updates.
- Existing players are not resized immediately; subsequent gameplay reads current limits. The exact `1.0` restoration exception remains intact.
## 2026-09-06T23:41:16Z — Restoration completed ## 2026-09-06T23:41:16Z — Restoration completed
- Added the white Potion of Restoration with persistent identity and the approved Sugar upgrade recipe. - Added the white Potion of Restoration with persistent identity and the approved Sugar upgrade recipe.
+1
View File
@@ -12,3 +12,4 @@ description: Catalog of user stories for the Spigot Heights plugin.
4. [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) 4. [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
5. [US-005: Build and release the plugin](us-005-build-and-release.md) 5. [US-005: Build and release the plugin](us-005-build-and-release.md)
6. [US-006: Restore default stature](us-006-restore-default-stature.md) 6. [US-006: Restore default stature](us-006-restore-default-stature.md)
7. [US-007: Manage stature settings with commands](us-007-manage-settings-commands.md)
@@ -19,8 +19,10 @@ As a **server operator**, I want validated stature and launcher settings with du
- [x] Missing state defaults safely to scale `1.0` clamped to the configured range. - [x] Missing state defaults safely to scale `1.0` clamped to the configured range.
- [x] Saved out-of-range state is clamped before it is applied, except exact scale `1.0`, which is preserved for restoration. - [x] Saved out-of-range state is clamped before it is applied, except exact scale `1.0`, which is preserved for restoration.
- [x] Configuration and state behavior have automated tests. - [x] Configuration and state behavior have automated tests.
- [x] Approved min/max commands validate and persist changes before publishing live settings to gameplay listeners, without immediately resizing players.
## Related ## Related
- [User-story catalog](index.md) - [User-story catalog](index.md)
- [US-007: Manage stature settings with commands](us-007-manage-settings-commands.md)
- [US-006: Restore default stature](us-006-restore-default-stature.md) - [US-006: Restore default stature](us-006-restore-default-stature.md)
@@ -0,0 +1,30 @@
---
type: User Story
title: "US-007: Manage stature settings with commands"
description: Let administrators view and persistently change stature bounds with tab-completed commands.
status: done
---
# US-007: Manage stature settings with commands
As a **server administrator**, I want commands with tab completion so that I can change stature limits without restarting the server.
## Acceptance criteria
- [x] `/heights settings` displays current settings; `/heights set min|max <value>` changes the selected bound.
- [x] Operators and senders with `spigotheights.admin` may use the commands, including console; unauthorized senders cannot change settings or receive suggestions.
- [x] Tab completion suggests subcommands, `min`/`max`, and valid example values, filtered by the typed prefix.
- [x] Successful changes apply immediately to gameplay and persist to `plugins/SpigotHeights/config.yml` without discarding unrelated configuration.
- [x] Invalid arguments or settings produce clear explanations and leave active and saved settings unchanged; persistence failure does not activate the new settings.
- [x] Existing players are not immediately resized; subsequent potion use, joins, and respawns use current limits, preserving the restoration exception for `1.0`.
- [x] Automated tests and README documentation cover commands, permissions, completion, validation, persistence, and live settings.
## Verification
- `./gradlew clean check jar` passed all 26 tests, including command execution with authorized/unauthorized generic senders, prefix-filtered valid completions, argument validation, failed saves, YAML reload/preservation, and live domain behavior.
- Code inspection confirms command/permission registration and shared settings suppliers in consumption, join, respawn, and launcher adapters. No live-server command or client tab-completion test was performed.
## Related
- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
- [US-006: Restore default stature](us-006-restore-default-stature.md)
@@ -0,0 +1,97 @@
package games.dmg.spigotheights;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor;
public final class HeightsCommand implements TabExecutor {
private static final String PERMISSION = "spigotheights.admin";
private static final String USAGE = "Usage: /heights settings | /heights set min|max <value>";
private final LiveHeightSettings settings;
private final Logger logger;
public HeightsCommand(LiveHeightSettings settings, Logger logger) {
this.settings = settings;
this.logger = logger;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You do not have permission to manage Spigot Heights settings.");
return true;
}
if (args.length == 1 && args[0].equalsIgnoreCase("settings")) {
HeightSettings current = settings.get();
sender.sendMessage("Spigot Heights: min=" + current.minimum() + ", max=" + current.maximum()
+ ", adjustment-step=" + current.adjustmentStep()
+ ", launcher-threshold=" + current.launcherThreshold()
+ ", launcher-speed=" + current.launcherSpeed()
+ ", launcher-cooldown-ticks=" + current.launcherCooldownTicks());
return true;
}
if (args.length != 3 || !args[0].equalsIgnoreCase("set") || !isBound(args[1])) {
sender.sendMessage(USAGE);
return true;
}
try {
String bound = args[1].toLowerCase(Locale.ROOT);
double value = Double.parseDouble(args[2]);
settings.setBound(bound, value);
sender.sendMessage("Set " + bound + " to " + value + ". Saved and active; existing players were not resized.");
} catch (NumberFormatException exception) {
sender.sendMessage("Invalid value: enter a finite number, for example 0.2 or 3.0.");
} catch (IllegalArgumentException exception) {
sender.sendMessage("Invalid settings: " + exception.getMessage());
} catch (IOException exception) {
logger.warning("Could not save height settings: " + exception.getMessage());
sender.sendMessage("Could not save settings. Active settings are unchanged; check the server log.");
}
return true;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String alias, String[] args) {
if (!sender.hasPermission(PERMISSION)) {
return List.of();
}
if (args.length == 1) {
return matching(Stream.of("settings", "set"), args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("set")) {
return matching(Stream.of("min", "max"), args[1]);
}
if (args.length == 3 && args[0].equalsIgnoreCase("set") && isBound(args[1])) {
String bound = args[1].toLowerCase(Locale.ROOT);
HeightSettings current = settings.get();
return matching(Stream.of(current.minimum(), current.maximum(), current.launcherThreshold(),
0.0625, 0.2, 0.4, 0.5, 1.0, 2.0, 3.0, 16.0)
.distinct().sorted().filter(value -> isValid(bound, value)).map(String::valueOf), args[2]);
}
// Never fall back to Bukkit's player-name completion.
return List.of();
}
private boolean isValid(String bound, double value) {
try {
settings.candidate(bound, value);
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
private static boolean isBound(String value) {
return value.equalsIgnoreCase("min") || value.equalsIgnoreCase("max");
}
private static List<String> matching(Stream<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.filter(value -> value.startsWith(normalized)).toList();
}
}
@@ -0,0 +1,42 @@
package games.dmg.spigotheights;
import java.io.IOException;
import java.util.function.Supplier;
/** Server-thread settings shared by commands and gameplay listeners. */
public final class LiveHeightSettings implements Supplier<HeightSettings> {
@FunctionalInterface
public interface Saver {
void save(HeightSettings settings) throws IOException;
}
private HeightSettings current;
private final Saver saver;
public LiveHeightSettings(HeightSettings initial, Saver saver) {
this.current = initial;
this.saver = saver;
}
@Override
public HeightSettings get() {
return current;
}
public HeightSettings candidate(String bound, double value) {
if (!bound.equals("min") && !bound.equals("max")) {
throw new IllegalArgumentException("Choose min or max.");
}
return new HeightSettings(
bound.equals("min") ? value : current.minimum(),
bound.equals("max") ? value : current.maximum(),
current.adjustmentStep(), current.launcherThreshold(),
current.launcherSpeed(), current.launcherCooldownTicks());
}
public void setBound(String bound, double value) throws IOException {
HeightSettings next = candidate(bound, value);
saver.save(next);
current = next;
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigotheights;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class SettingsFileStore {
private final Path path;
public SettingsFileStore(Path path) {
this.path = path.toAbsolutePath();
}
public void save(HeightSettings settings) throws IOException {
// Read a separate document so failed writes cannot mutate the active configuration.
YamlConfiguration config = new YamlConfiguration();
try {
config.load(path.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Existing config.yml is invalid; refusing to overwrite it", exception);
}
config.set("height.minimum", settings.minimum());
config.set("height.maximum", settings.maximum());
Path temporary = Files.createTempFile(path.getParent(), "config-", ".yml.tmp");
try {
Files.writeString(temporary, config.saveToString(), StandardCharsets.UTF_8);
try {
Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
}
@@ -1,5 +1,7 @@
package games.dmg.spigotheights; package games.dmg.spigotheights;
import java.util.Objects;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -16,12 +18,23 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
return; return;
} }
SettingsFileStore configStore = new SettingsFileStore(getDataFolder().toPath().resolve("config.yml"));
LiveHeightSettings liveSettings = new LiveHeightSettings(settings, next -> {
configStore.save(next);
getConfig().set("height.minimum", next.minimum());
getConfig().set("height.maximum", next.maximum());
});
HeightsCommand executor = new HeightsCommand(liveSettings, getLogger());
PluginCommand command = Objects.requireNonNull(getCommand("heights"), "Missing heights command declaration");
command.setExecutor(executor);
command.setTabCompleter(executor);
HeightStore store = new HeightStore(getDataFolder()); HeightStore store = new HeightStore(getDataFolder());
PotionRecipes potions = new PotionRecipes(this); PotionRecipes potions = new PotionRecipes(this);
potions.register(); potions.register();
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new StatureListener(this, settings, store, potions), this); new StatureListener(this, liveSettings, store, potions), this);
getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(settings), this); getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(liveSettings), this);
getLogger().info("Spigot Heights enabled."); getLogger().info("Spigot Heights enabled.");
} }
@@ -2,6 +2,7 @@ package games.dmg.spigotheights;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
import org.bukkit.attribute.Attribute; import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance; import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -19,11 +20,11 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class StatureListener implements Listener { public final class StatureListener implements Listener {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final HeightSettings settings; private final Supplier<HeightSettings> settings;
private final HeightStore store; private final HeightStore store;
private final PotionRecipes potions; private final PotionRecipes potions;
public StatureListener(JavaPlugin plugin, HeightSettings settings, HeightStore store, PotionRecipes potions) { public StatureListener(JavaPlugin plugin, Supplier<HeightSettings> settings, HeightStore store, PotionRecipes potions) {
this.plugin = plugin; this.plugin = plugin;
this.settings = settings; this.settings = settings;
this.store = store; this.store = store;
@@ -50,7 +51,7 @@ public final class StatureListener implements Listener {
} }
Player player = event.getPlayer(); Player player = event.getPlayer();
double current = currentScale(player); double current = currentScale(player);
double scale = kind.scaleAfterDrinking(current, settings, double scale = kind.scaleAfterDrinking(current, settings.get(),
bound -> ThreadLocalRandom.current().nextInt(bound)); bound -> ThreadLocalRandom.current().nextInt(bound));
applyAndSave(player, scale); applyAndSave(player, scale);
player.sendMessage("Your scale is now " + scale + "."); player.sendMessage("Your scale is now " + scale + ".");
@@ -58,14 +59,14 @@ public final class StatureListener implements Listener {
@EventHandler @EventHandler
public void onJoin(PlayerJoinEvent event) { public void onJoin(PlayerJoinEvent event) {
apply(event.getPlayer(), HeightMath.safeStoredScale(store.find(event.getPlayer().getUniqueId()), settings)); apply(event.getPlayer(), HeightMath.safeStoredScale(store.find(event.getPlayer().getUniqueId()), settings.get()));
} }
@EventHandler @EventHandler
public void onRespawn(PlayerRespawnEvent event) { public void onRespawn(PlayerRespawnEvent event) {
plugin.getServer().getScheduler().runTask(plugin, () -> { plugin.getServer().getScheduler().runTask(plugin, () -> {
Player player = event.getPlayer(); Player player = event.getPlayer();
apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings)); apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings.get()));
}); });
} }
@@ -3,6 +3,7 @@ package games.dmg.spigotheights;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
@@ -19,11 +20,11 @@ import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
public final class TinyPlayerLauncher implements Listener { public final class TinyPlayerLauncher implements Listener {
private final HeightSettings settings; private final Supplier<HeightSettings> settingsSupplier;
private final Map<UUID, Long> lastLaunchTicks = new HashMap<>(); private final Map<UUID, Long> lastLaunchTicks = new HashMap<>();
public TinyPlayerLauncher(HeightSettings settings) { public TinyPlayerLauncher(Supplier<HeightSettings> settingsSupplier) {
this.settings = settings; this.settingsSupplier = settingsSupplier;
} }
@EventHandler(ignoreCancelled = true) @EventHandler(ignoreCancelled = true)
@@ -32,6 +33,7 @@ public final class TinyPlayerLauncher implements Listener {
if (destination == null || sameBlock(event.getFrom(), destination)) { if (destination == null || sameBlock(event.getFrom(), destination)) {
return; return;
} }
HeightSettings settings = settingsSupplier.get();
Player player = event.getPlayer(); Player player = event.getPlayer();
if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) { if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) {
return; return;
+9
View File
@@ -4,3 +4,12 @@ main: games.dmg.spigotheights.SpigotHeightsPlugin
api-version: "1.21" api-version: "1.21"
description: Craftable player stature potions and tiny-player dispenser launchers. description: Craftable player stature potions and tiny-player dispenser launchers.
author: dmg.games author: dmg.games
commands:
heights:
description: View and change Spigot Heights stature limits.
usage: /heights settings | /heights set min|max <value>
permission: spigotheights.admin
permissions:
spigotheights.admin:
description: View and change Spigot Heights settings.
default: op
@@ -0,0 +1,111 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
class HeightsCommandTest {
private final List<HeightSettings> saved = new ArrayList<>();
private final List<String> messages = new ArrayList<>();
private final LiveHeightSettings live = new LiveHeightSettings(
new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20), saved::add);
private final HeightsCommand command = new HeightsCommand(live, Logger.getAnonymousLogger());
@Test
void authorizedSenderCanInspectAndChangeBounds() {
CommandSender sender = sender(true);
command.onCommand(sender, null, "heights", new String[] {"settings"});
assertTrue(messages.getLast().contains("min=0.4"));
assertTrue(messages.getLast().contains("max=2.0"));
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
command.onCommand(sender, null, "heights", new String[] {"set", "max", "3"});
assertEquals(0.2, live.get().minimum());
assertEquals(3.0, live.get().maximum());
assertEquals(2, saved.size());
assertTrue(messages.getLast().contains("3.0"));
}
@Test
void unauthorizedSenderCannotInspectMutateOrComplete() {
CommandSender sender = sender(false);
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
command.onCommand(sender, null, "heights", new String[] {"settings"});
assertTrue(messages.stream().allMatch(message -> message.contains("permission")));
assertEquals(List.of(), saved);
assertEquals(List.of(), command.onTabComplete(sender, null, "heights", new String[] {""}));
}
@Test
void malformedAndInvalidInputNeverSaves() {
for (String[] args : new String[][] {
{}, {"set"}, {"set", "min"}, {"set", "min", "0.2", "extra"},
{"settings", "extra"}, {"unknown"}, {"set", "speed", "1"},
{"set", "min", "nope"}, {"set", "min", "NaN"},
{"set", "max", "Infinity"}, {"set", "min", "0.6"}, {"set", "max", "0.3"}}) {
int before = messages.size();
assertTrue(command.onCommand(sender(true), null, "heights", args));
assertEquals(before + 1, messages.size());
}
assertEquals(List.of(), saved);
assertEquals(0.4, live.get().minimum());
assertEquals(2.0, live.get().maximum());
}
@Test
void completionsArePrefixFilteredAndValidForCurrentRange() throws Exception {
CommandSender sender = sender(true);
assertEquals(List.of("settings", "set"), complete(sender, "s"));
assertEquals(List.of("settings"), complete(sender, "sett"));
assertEquals(List.of("min", "max"), complete(sender, "set", "m"));
assertEquals(List.of("max"), complete(sender, "set", "ma"));
assertEquals(List.of(), complete(sender, "settings", ""));
assertEquals(List.of(), complete(sender, "set", "other", ""));
assertEquals(List.of(), complete(sender, "set", "min", "0.2", ""));
assertTrue(complete(sender, "set", "min", "0.").contains("0.2"));
for (String bound : List.of("min", "max")) {
for (String value : complete(sender, "set", bound, "")) {
live.candidate(bound, Double.parseDouble(value));
}
}
live.setBound("max", 0.8);
assertTrue(complete(sender, "set", "max", "0.").contains("0.8"));
assertEquals(List.of(), complete(sender, "set", "min", "1"));
}
@Test
void saveFailureIsReportedWithoutChangingActiveSettings() {
LiveHeightSettings failing = new LiveHeightSettings(live.get(), settings -> {
throw new IOException("test save failure");
});
new HeightsCommand(failing, Logger.getAnonymousLogger()).onCommand(
sender(true), null, "heights", new String[] {"set", "min", "0.2"});
assertTrue(messages.getLast().contains("Could not save"));
assertEquals(0.4, failing.get().minimum());
}
private List<String> complete(CommandSender sender, String... args) {
return command.onTabComplete(sender, null, "heights", args);
}
private CommandSender sender(boolean permitted) {
return (CommandSender) Proxy.newProxyInstance(CommandSender.class.getClassLoader(),
new Class<?>[] {CommandSender.class}, (proxy, method, args) -> {
if (method.getName().equals("hasPermission")) {
assertEquals("spigotheights.admin", args[0]);
return permitted;
}
if (method.getName().equals("sendMessage") && args[0] instanceof String text) {
messages.add(text);
return null;
}
throw new UnsupportedOperationException(method.getName());
});
}
}
@@ -0,0 +1,50 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class LiveHeightSettingsTest {
private static final HeightSettings DEFAULTS = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
@Test
void savesBeforePublishingAndGameplayReadsNewBounds() throws Exception {
List<HeightSettings> saved = new ArrayList<>();
LiveHeightSettings live = new LiveHeightSettings(DEFAULTS, saved::add);
live.setBound("min", 0.2);
live.setBound("max", 3.0);
assertEquals(2, saved.size());
assertSame(saved.getLast(), live.get());
assertEquals(0.2, HeightMath.randomScale(live.get(), bound -> 0));
assertEquals(3.0, HeightMath.grow(3.0, live.get()));
assertEquals(0.2, HeightMath.safeStoredScale(0.1, live.get()));
assertEquals(1.0, HeightMath.safeStoredScale(1.0, live.get()));
assertEquals(0.1, live.get().adjustmentStep());
assertEquals(0.5, live.get().launcherThreshold());
}
@Test
void invalidChangesAndSaveFailuresDoNotPublish() {
List<HeightSettings> saved = new ArrayList<>();
LiveHeightSettings live = new LiveHeightSettings(DEFAULTS, saved::add);
for (double value : new double[] {Double.NaN, Double.POSITIVE_INFINITY, -1, 0, 0.01, 0.6, 3}) {
assertThrows(IllegalArgumentException.class, () -> live.setBound("min", value));
}
for (double value : new double[] {0.3, 0.45, 17, Double.NEGATIVE_INFINITY}) {
assertThrows(IllegalArgumentException.class, () -> live.setBound("max", value));
}
assertThrows(IllegalArgumentException.class, () -> live.setBound("speed", 1));
assertEquals(List.of(), saved);
assertSame(DEFAULTS, live.get());
LiveHeightSettings failing = new LiveHeightSettings(DEFAULTS, settings -> {
throw new IOException("disk full");
});
assertThrows(IOException.class, () -> failing.setBound("min", 0.2));
assertSame(DEFAULTS, failing.get());
}
}
@@ -0,0 +1,47 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SettingsFileStoreTest {
@TempDir
Path directory;
@Test
void savedBoundsSurviveReloadAndPreserveOtherConfiguration() throws Exception {
Path config = directory.resolve("config.yml");
try (var defaults = getClass().getResourceAsStream("/config.yml")) {
Files.copy(defaults, config);
}
Files.writeString(config, Files.readString(config) + "\nfuture-setting: retained\n");
HeightSettings initial = SpigotHeightsPlugin.loadSettings(YamlConfiguration.loadConfiguration(config.toFile()));
LiveHeightSettings live = new LiveHeightSettings(initial, new SettingsFileStore(config)::save);
live.setBound("min", 0.2);
live.setBound("max", 3.0);
YamlConfiguration reloaded = YamlConfiguration.loadConfiguration(config.toFile());
assertEquals(live.get(), SpigotHeightsPlugin.loadSettings(reloaded));
assertEquals("retained", reloaded.getString("future-setting"));
try (var files = Files.list(directory)) {
assertEquals(1, files.count());
}
}
@Test
void malformedConfigurationIsNotOverwrittenOrActivated() throws Exception {
Path config = directory.resolve("config.yml");
String malformed = "height: [unterminated";
Files.writeString(config, malformed);
HeightSettings initial = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
LiveHeightSettings live = new LiveHeightSettings(initial, new SettingsFileStore(config)::save);
assertThrows(IOException.class, () -> live.setBound("min", 0.2));
assertEquals(initial, live.get());
assertEquals(malformed, Files.readString(config));
}
}