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;