feat(commands): manage height bounds with tab completion
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
- Added the white Potion of Restoration with persistent identity and the approved Sugar upgrade recipe.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HeightSettings> settings;
|
||||
private final HeightStore store;
|
||||
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.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()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HeightSettings> settingsSupplier;
|
||||
private final Map<UUID, Long> lastLaunchTicks = new HashMap<>();
|
||||
|
||||
public TinyPlayerLauncher(HeightSettings settings) {
|
||||
this.settings = settings;
|
||||
public TinyPlayerLauncher(Supplier<HeightSettings> 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;
|
||||
|
||||
@@ -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 <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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user