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
@@ -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;
+9
View File
@@ -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));
}
}