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(
+3 -3
View File
@@ -6,10 +6,10 @@ description: Craftable player stature potions and tiny-player dispenser launcher
author: dmg.games
commands:
heights:
description: View and change Spigot Heights stature limits.
usage: /heights settings | /heights set min|max <value>
description: View and change stature limits and online player heights.
usage: /heights settings | /heights set min|max <value> | /heights player <name> [scale]
permission: spigotheights.admin
permissions:
spigotheights.admin:
description: View and change Spigot Heights settings.
description: View and change Spigot Heights settings and player heights.
default: op
@@ -1,6 +1,9 @@
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;
@@ -12,6 +15,18 @@ class HeightStoreTest {
@TempDir
Path temporaryDirectory;
@Test
void failedSaveDoesNotChangeCachedOrSavedScale() throws Exception {
UUID playerId = UUID.randomUUID();
HeightStore store = new HeightStore(temporaryDirectory.toFile());
store.save(playerId, 0.7);
String original = Files.readString(temporaryDirectory.resolve("state.yml"));
Files.createDirectory(temporaryDirectory.resolve("state.yml.tmp"));
assertThrows(IOException.class, () -> store.save(playerId, 0.2));
assertEquals(0.7, store.find(playerId));
assertEquals(original, Files.readString(temporaryDirectory.resolve("state.yml")));
}
@Test
void restorationReplacesPreviousSizeAndSurvivesReloadOutsideLimits() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -5,18 +5,77 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Proxy;
import java.nio.file.Path;
import java.util.UUID;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.io.TempDir;
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());
@TempDir
Path directory;
private final UUID playerId = UUID.randomUUID();
private double playerScale = 0.7;
private PlayerHeights players;
private HeightStore playerStore;
private HeightsCommand command;
@BeforeEach
void setup() {
playerStore = new HeightStore(directory.toFile());
players = new PlayerHeights(live, playerStore, name -> name.equalsIgnoreCase("Steve")
? new PlayerHeights.Target(playerId, "Steve", playerScale, playerScale, value -> playerScale = value)
: null, () -> List.of("Steve"));
command = new HeightsCommand(live, players, Logger.getAnonymousLogger());
}
@Test
void viewsAndSetsOnlinePlayerWithNameAndSizeCompletion() {
CommandSender sender = sender(true);
command.onCommand(sender, null, "heights", new String[] {"player", "Steve"});
assertTrue(messages.getLast().contains("0.7"));
command.onCommand(sender, null, "heights", new String[] {"player", "steve", "0.5"});
assertEquals(0.5, playerScale);
assertEquals(0.5, playerStore.find(playerId));
assertTrue(messages.getLast().contains("Steve"));
assertEquals(List.of("player"), complete(sender, "pl"));
assertEquals(List.of("Steve"), complete(sender, "player", "st"));
assertTrue(complete(sender, "player", "Steve", "0.").contains("0.5"));
assertTrue(complete(sender, "player", "Steve", "1").contains("1.0"));
assertEquals(List.of(), complete(sender, "player", "Offline", ""));
assertEquals(List.of(), complete(sender, "player", "Steve", "0.5", ""));
for (String value : complete(sender, "player", "Steve", "")) {
assertTrue(players.isValid(Double.parseDouble(value)));
}
}
@Test
void invalidPlayerCommandsAndFailedSavesAreReportedWithoutResizing() throws Exception {
for (String[] args : new String[][] {
{"player"}, {"player", "Steve", "0.5", "extra"},
{"player", "Offline"}, {"player", "Ste", "0.5"},
{"player", "Steve", "nope"}, {"player", "Steve", "NaN"},
{"player", "Steve", "0.2"}, {"player", "Steve", "3.0"}}) {
int before = messages.size();
command.onCommand(sender(true), null, "heights", args);
assertEquals(before + 1, messages.size());
assertEquals(0.7, playerScale);
assertEquals(null, playerStore.find(playerId));
}
java.nio.file.Files.createDirectory(directory.resolve("state.yml.tmp"));
command.onCommand(sender(true), null, "heights", new String[] {"player", "Steve", "0.5"});
assertTrue(messages.getLast().contains("Could not save"));
assertEquals(0.7, playerScale);
assertEquals(null, playerStore.find(playerId));
}
@Test
void authorizedSenderCanInspectAndChangeBounds() {
@@ -37,6 +96,12 @@ class HeightsCommandTest {
CommandSender sender = sender(false);
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
command.onCommand(sender, null, "heights", new String[] {"settings"});
command.onCommand(sender, null, "heights", new String[] {"player", "Steve"});
command.onCommand(sender, null, "heights", new String[] {"player", "Steve", "0.5"});
assertEquals(0.7, playerScale);
assertEquals(null, playerStore.find(playerId));
assertEquals(List.of(), complete(sender, "player", ""));
assertEquals(List.of(), complete(sender, "player", "Steve", ""));
assertTrue(messages.stream().allMatch(message -> message.contains("permission")));
assertEquals(List.of(), saved);
assertEquals(List.of(), command.onTabComplete(sender, null, "heights", new String[] {""}));
@@ -84,7 +149,7 @@ class HeightsCommandTest {
LiveHeightSettings failing = new LiveHeightSettings(live.get(), settings -> {
throw new IOException("test save failure");
});
new HeightsCommand(failing, Logger.getAnonymousLogger()).onCommand(
new HeightsCommand(failing, players, 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());
@@ -0,0 +1,78 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class PlayerHeightsTest {
@TempDir
Path directory;
private final UUID id = UUID.randomUUID();
private double scale = 0.7;
private LiveHeightSettings settings;
private HeightStore store;
private PlayerHeights heights;
@BeforeEach
void setup() {
settings = new LiveHeightSettings(new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20), value -> {});
store = new HeightStore(directory.toFile());
heights = new PlayerHeights(settings, store, name -> name.equalsIgnoreCase("Steve")
? new PlayerHeights.Target(id, "Steve", scale, scale * 2, value -> scale = value) : null,
() -> List.of("Steve"));
}
@Test
void viewsActualBaseAndEffectiveScaleAndPersistsChangesByUuid() throws Exception {
assertTrue(heights.describe("steve").contains("0.7"));
assertTrue(heights.describe("Steve").contains("1.4"));
heights.set("STEVE", 0.5);
assertEquals(0.5, scale);
HeightStore reloaded = new HeightStore(directory.toFile());
assertEquals(0.5, reloaded.find(id));
assertEquals(0.5, HeightMath.safeStoredScale(reloaded.find(id), settings.get()));
assertEquals(List.of("Steve"), heights.names());
}
@Test
void rejectsUnknownPlayersAndInvalidScalesWithoutChangingState() {
assertThrows(IllegalArgumentException.class, () -> heights.describe("Ste"));
assertThrows(IllegalArgumentException.class, () -> heights.set("Offline", 0.5));
for (double value : new double[] {Double.NaN, Double.POSITIVE_INFINITY, -1, 0.2, 3.0}) {
assertThrows(IllegalArgumentException.class, () -> heights.set("Steve", value));
}
assertEquals(0.7, scale);
assertEquals(null, store.find(id));
}
@Test
void usesLiveLimitsAndAllowsRestorationOutsideThem() throws Exception {
settings.setBound("max", 0.8);
heights.set("Steve", 1.0);
assertEquals(1.0, scale);
assertEquals(1.0, HeightMath.safeStoredScale(new HeightStore(directory.toFile()).find(id), settings.get()));
assertThrows(IllegalArgumentException.class, () -> heights.set("Steve", 0.9));
settings.setBound("min", 0.2);
heights.set("Steve", 0.2);
assertEquals(0.2, scale);
}
@Test
void failedSaveLeavesLiveAndCachedHeightUnchanged() throws Exception {
store.save(id, scale);
Files.createDirectory(directory.resolve("state.yml.tmp"));
assertThrows(IOException.class, () -> heights.set("Steve", 0.5));
assertEquals(0.7, scale);
assertEquals(0.7, store.find(id));
assertEquals(0.7, new HeightStore(directory.toFile()).find(id));
}
}