From e802319f45f63e2d2e806a39187751cbc7d49d7e Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sun, 6 Sep 2026 19:53:19 -0400 Subject: [PATCH] feat(commands): manage height bounds with tab completion --- README.md | 16 +++ design/log.md | 12 ++ design/user-stories/index.md | 1 + .../us-004-configure-and-persist.md | 2 + .../us-007-manage-settings-commands.md | 30 +++++ .../dmg/spigotheights/HeightsCommand.java | 97 +++++++++++++++ .../dmg/spigotheights/LiveHeightSettings.java | 42 +++++++ .../dmg/spigotheights/SettingsFileStore.java | 41 +++++++ .../spigotheights/SpigotHeightsPlugin.java | 17 ++- .../dmg/spigotheights/StatureListener.java | 11 +- .../dmg/spigotheights/TinyPlayerLauncher.java | 8 +- src/main/resources/plugin.yml | 9 ++ .../dmg/spigotheights/HeightsCommandTest.java | 111 ++++++++++++++++++ .../spigotheights/LiveHeightSettingsTest.java | 50 ++++++++ .../spigotheights/SettingsFileStoreTest.java | 47 ++++++++ 15 files changed, 484 insertions(+), 10 deletions(-) create mode 100644 design/user-stories/us-007-manage-settings-commands.md create mode 100644 src/main/java/games/dmg/spigotheights/HeightsCommand.java create mode 100644 src/main/java/games/dmg/spigotheights/LiveHeightSettings.java create mode 100644 src/main/java/games/dmg/spigotheights/SettingsFileStore.java create mode 100644 src/test/java/games/dmg/spigotheights/HeightsCommandTest.java create mode 100644 src/test/java/games/dmg/spigotheights/LiveHeightSettingsTest.java create mode 100644 src/test/java/games/dmg/spigotheights/SettingsFileStoreTest.java diff --git a/README.md b/README.md index 56e1cbf..67fea0f 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,22 @@ launcher: 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 ```bash diff --git a/design/log.md b/design/log.md index ddd2756..1674792 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting Spigot Heights # Spigot Heights Design Log +## 2026-09-06T23:51:22Z — Settings commands completed + +- Added `/heights settings` and `/heights set min|max ` 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 - Added the white Potion of Restoration with persistent identity and the approved Sugar upgrade recipe. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 1002483..591a0c1 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -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) 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) +7. [US-007: Manage stature settings with commands](us-007-manage-settings-commands.md) diff --git a/design/user-stories/us-004-configure-and-persist.md b/design/user-stories/us-004-configure-and-persist.md index af39bca..a9a62a3 100644 --- a/design/user-stories/us-004-configure-and-persist.md +++ b/design/user-stories/us-004-configure-and-persist.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] 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] Approved min/max commands validate and persist changes before publishing live settings to gameplay listeners, without immediately resizing players. ## Related - [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) diff --git a/design/user-stories/us-007-manage-settings-commands.md b/design/user-stories/us-007-manage-settings-commands.md new file mode 100644 index 0000000..62d7d04 --- /dev/null +++ b/design/user-stories/us-007-manage-settings-commands.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 ` 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) diff --git a/src/main/java/games/dmg/spigotheights/HeightsCommand.java b/src/main/java/games/dmg/spigotheights/HeightsCommand.java new file mode 100644 index 0000000..0600c55 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/HeightsCommand.java @@ -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 "; + 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 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 matching(Stream candidates, String prefix) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return candidates.filter(value -> value.startsWith(normalized)).toList(); + } +} diff --git a/src/main/java/games/dmg/spigotheights/LiveHeightSettings.java b/src/main/java/games/dmg/spigotheights/LiveHeightSettings.java new file mode 100644 index 0000000..d56e2a9 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/LiveHeightSettings.java @@ -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 { + @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; + } +} diff --git a/src/main/java/games/dmg/spigotheights/SettingsFileStore.java b/src/main/java/games/dmg/spigotheights/SettingsFileStore.java new file mode 100644 index 0000000..fc459fd --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/SettingsFileStore.java @@ -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); + } + } +} diff --git a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java index 63ca6ac..9898fc7 100644 --- a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java +++ b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java @@ -1,5 +1,7 @@ package games.dmg.spigotheights; +import java.util.Objects; +import org.bukkit.command.PluginCommand; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.plugin.java.JavaPlugin; @@ -16,12 +18,23 @@ public final class SpigotHeightsPlugin extends JavaPlugin { 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()); PotionRecipes potions = new PotionRecipes(this); potions.register(); getServer().getPluginManager().registerEvents( - new StatureListener(this, settings, store, potions), this); - getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(settings), this); + new StatureListener(this, liveSettings, store, potions), this); + getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(liveSettings), this); getLogger().info("Spigot Heights enabled."); } diff --git a/src/main/java/games/dmg/spigotheights/StatureListener.java b/src/main/java/games/dmg/spigotheights/StatureListener.java index 7953977..413392c 100644 --- a/src/main/java/games/dmg/spigotheights/StatureListener.java +++ b/src/main/java/games/dmg/spigotheights/StatureListener.java @@ -2,6 +2,7 @@ package games.dmg.spigotheights; import java.io.IOException; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Supplier; import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; import org.bukkit.entity.Player; @@ -19,11 +20,11 @@ import org.bukkit.plugin.java.JavaPlugin; public final class StatureListener implements Listener { private final JavaPlugin plugin; - private final HeightSettings settings; + private final Supplier settings; private final HeightStore store; private final PotionRecipes potions; - public StatureListener(JavaPlugin plugin, HeightSettings settings, HeightStore store, PotionRecipes potions) { + public StatureListener(JavaPlugin plugin, Supplier settings, HeightStore store, PotionRecipes potions) { this.plugin = plugin; this.settings = settings; this.store = store; @@ -50,7 +51,7 @@ public final class StatureListener implements Listener { } Player player = event.getPlayer(); double current = currentScale(player); - double scale = kind.scaleAfterDrinking(current, settings, + double scale = kind.scaleAfterDrinking(current, settings.get(), bound -> ThreadLocalRandom.current().nextInt(bound)); applyAndSave(player, scale); player.sendMessage("Your scale is now " + scale + "."); @@ -58,14 +59,14 @@ public final class StatureListener implements Listener { @EventHandler 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 public void onRespawn(PlayerRespawnEvent event) { plugin.getServer().getScheduler().runTask(plugin, () -> { Player player = event.getPlayer(); - apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings)); + apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings.get())); }); } diff --git a/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java b/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java index e58f157..7d553ec 100644 --- a/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java +++ b/src/main/java/games/dmg/spigotheights/TinyPlayerLauncher.java @@ -3,6 +3,7 @@ package games.dmg.spigotheights; import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; @@ -19,11 +20,11 @@ import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.util.Vector; public final class TinyPlayerLauncher implements Listener { - private final HeightSettings settings; + private final Supplier settingsSupplier; private final Map lastLaunchTicks = new HashMap<>(); - public TinyPlayerLauncher(HeightSettings settings) { - this.settings = settings; + public TinyPlayerLauncher(Supplier settingsSupplier) { + this.settingsSupplier = settingsSupplier; } @EventHandler(ignoreCancelled = true) @@ -32,6 +33,7 @@ public final class TinyPlayerLauncher implements Listener { if (destination == null || sameBlock(event.getFrom(), destination)) { return; } + HeightSettings settings = settingsSupplier.get(); Player player = event.getPlayer(); if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) { return; diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7023502..7c8e6cf 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,3 +4,12 @@ main: games.dmg.spigotheights.SpigotHeightsPlugin api-version: "1.21" description: Craftable player stature potions and tiny-player dispenser launchers. author: dmg.games +commands: + heights: + description: View and change Spigot Heights stature limits. + usage: /heights settings | /heights set min|max + permission: spigotheights.admin +permissions: + spigotheights.admin: + description: View and change Spigot Heights settings. + default: op diff --git a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java new file mode 100644 index 0000000..64656e3 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java @@ -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 saved = new ArrayList<>(); + private final List 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 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()); + }); + } +} diff --git a/src/test/java/games/dmg/spigotheights/LiveHeightSettingsTest.java b/src/test/java/games/dmg/spigotheights/LiveHeightSettingsTest.java new file mode 100644 index 0000000..73c5c4a --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/LiveHeightSettingsTest.java @@ -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 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 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()); + } +} diff --git a/src/test/java/games/dmg/spigotheights/SettingsFileStoreTest.java b/src/test/java/games/dmg/spigotheights/SettingsFileStoreTest.java new file mode 100644 index 0000000..25cde25 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/SettingsFileStoreTest.java @@ -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)); + } +}