feat(commands): view and set online player heights
Release / release (push) Successful in 2m54s
CI / build (push) Successful in 1m2s

This commit is contained in:
dmg
2026-09-06 20:05:41 -04:00
parent e802319f45
commit f011410b17
15 changed files with 380 additions and 16 deletions
@@ -0,0 +1,34 @@
package games.dmg.spigotheights;
import java.util.List;
import org.bukkit.Server;
import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player;
/** Bukkit boundary for exact online-player lookup; used only on the server thread. */
public final class BukkitPlayerHeights {
private final Server server;
public BukkitPlayerHeights(Server server) {
this.server = server;
}
public PlayerHeights.Target find(String name) {
Player player = server.getPlayerExact(name);
if (player == null) {
return null;
}
AttributeInstance scale = player.getAttribute(Attribute.SCALE);
if (scale == null) {
throw new IllegalArgumentException("Player " + player.getName() + " has no scale attribute.");
}
return new PlayerHeights.Target(player.getUniqueId(), player.getName(), scale.getBaseValue(),
scale.getValue(), scale::setBaseValue);
}
public List<String> names() {
return server.getOnlinePlayers().stream().map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER).toList();
}
}
@@ -8,11 +8,12 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class HeightStore {
private final Path statePath;
private final YamlConfiguration state;
private YamlConfiguration state;
public HeightStore(File dataFolder) {
statePath = dataFolder.toPath().resolve("state.yml");
@@ -25,16 +26,23 @@ public final class HeightStore {
}
public synchronized void save(UUID playerId, double scale) throws IOException {
state.set(path(playerId), scale);
YamlConfiguration next = new YamlConfiguration();
try {
next.loadFromString(state.saveToString());
} catch (InvalidConfigurationException exception) {
throw new IOException("Could not copy player state", exception);
}
next.set(path(playerId), scale);
Files.createDirectories(statePath.getParent());
Path temporary = statePath.resolveSibling("state.yml.tmp");
Files.writeString(temporary, state.saveToString(), StandardCharsets.UTF_8);
Files.writeString(temporary, next.saveToString(), StandardCharsets.UTF_8);
try {
Files.move(temporary, statePath, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, statePath, StandardCopyOption.REPLACE_EXISTING);
}
state = next;
}
private static String path(UUID playerId) {
@@ -11,21 +11,27 @@ 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 static final String USAGE = "Usage: /heights settings | /heights set min|max <value>"
+ " | /heights player <name> [scale]";
private final LiveHeightSettings settings;
private final Logger logger;
private final PlayerHeights players;
public HeightsCommand(LiveHeightSettings settings, Logger logger) {
public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger) {
this.settings = settings;
this.players = players;
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.");
sender.sendMessage("You do not have permission to manage Spigot Heights.");
return true;
}
if (args.length > 0 && args[0].equalsIgnoreCase("player")) {
return playerCommand(sender, args);
}
if (args.length == 1 && args[0].equalsIgnoreCase("settings")) {
HeightSettings current = settings.get();
sender.sendMessage("Spigot Heights: min=" + current.minimum() + ", max=" + current.maximum()
@@ -55,13 +61,41 @@ public final class HeightsCommand implements TabExecutor {
return true;
}
private boolean playerCommand(CommandSender sender, String[] args) {
if (args.length != 2 && args.length != 3) {
sender.sendMessage(USAGE);
return true;
}
try {
sender.sendMessage(args.length == 2 ? players.describe(args[1])
: players.set(args[1], Double.parseDouble(args[2])));
} catch (NumberFormatException exception) {
sender.sendMessage("Invalid scale: enter a finite number, for example 0.5 or 1.0.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
logger.warning("Could not save player height: " + exception.getMessage());
sender.sendMessage("Could not save player height. Their height is 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]);
return matching(Stream.of("settings", "set", "player"), args[0]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("player")) {
return matching(players.names().stream(), args[1]);
}
if (args.length == 3 && args[0].equalsIgnoreCase("player")
&& players.names().stream().anyMatch(name -> name.equalsIgnoreCase(args[1]))) {
HeightSettings current = settings.get();
return matching(Stream.of(current.minimum(), current.maximum(), 0.0625, 0.2, 0.4, 0.5, 1.0, 2.0, 3.0, 16.0)
.distinct().sorted().filter(players::isValid).map(String::valueOf), args[2]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("set")) {
return matching(Stream.of("min", "max"), args[1]);
@@ -92,6 +126,6 @@ public final class HeightsCommand implements TabExecutor {
private static List<String> matching(Stream<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.filter(value -> value.startsWith(normalized)).toList();
return candidates.filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized)).toList();
}
}
@@ -0,0 +1,65 @@
package games.dmg.spigotheights;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import java.util.function.DoubleConsumer;
import java.util.function.Function;
import java.util.function.Supplier;
/** Online-player operations, called on the server thread. */
public final class PlayerHeights {
public record Target(UUID id, String name, double baseScale, double effectiveScale, DoubleConsumer applyScale) {}
private final Supplier<HeightSettings> settings;
private final HeightStore store;
private final Function<String, Target> lookup;
private final Supplier<List<String>> onlineNames;
public PlayerHeights(Supplier<HeightSettings> settings, HeightStore store,
Function<String, Target> lookup, Supplier<List<String>> onlineNames) {
this.settings = settings;
this.store = store;
this.lookup = lookup;
this.onlineNames = onlineNames;
}
public String describe(String name) {
Target target = requireTarget(name);
String message = target.name() + " has scale " + target.baseScale() + " (1.0 = normal)";
if (target.effectiveScale() != target.baseScale()) {
message += "; effective scale with modifiers: " + target.effectiveScale();
}
return message + ".";
}
public String set(String name, double value) throws IOException {
Target target = requireTarget(name);
if (!isValid(value)) {
HeightSettings current = settings.get();
throw new IllegalArgumentException("Scale must be a finite number between " + current.minimum()
+ " and " + current.maximum() + ", or exactly 1.0 for normal size.");
}
store.save(target.id(), value);
target.applyScale().accept(value);
return "Set " + target.name() + "'s base scale to " + value + ". Saved and active.";
}
public List<String> names() {
return onlineNames.get();
}
public boolean isValid(double value) {
HeightSettings current = settings.get();
return Double.isFinite(value) && (value == 1.0
|| value >= current.minimum() && value <= current.maximum());
}
private Target requireTarget(String name) {
Target target = lookup.apply(name);
if (target == null) {
throw new IllegalArgumentException("No online player named '" + name + "'. Use their full name.");
}
return target;
}
}
@@ -24,12 +24,14 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
getConfig().set("height.minimum", next.minimum());
getConfig().set("height.maximum", next.maximum());
});
HeightsCommand executor = new HeightsCommand(liveSettings, getLogger());
HeightStore store = new HeightStore(getDataFolder());
BukkitPlayerHeights onlinePlayers = new BukkitPlayerHeights(getServer());
PlayerHeights playerHeights = new PlayerHeights(liveSettings, store, onlinePlayers::find, onlinePlayers::names);
HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, 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(