diff --git a/README.md b/README.md index 67fea0f..1620142 100644 --- a/README.md +++ b/README.md @@ -72,13 +72,23 @@ Operators (or senders granted `spigotheights.admin`) can use: /heights settings /heights set min 0.2 /heights set max 3.0 +/heights player Steve +/heights player Steve 0.5 ``` 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. +Players are not immediately resized. Subsequent potion use, joins, and respawns use the new limits; Restoration still returns and preserves exact scale `1.0`. The settings commands only change min/max; edit other settings in the configuration file and restart the server. + +### Player height + +`/heights player ` reports an online player's scale (`1.0` = normal size). If another plugin adds scale modifiers, both the base scale and effective scale are shown. + +`/heights player ` immediately sets and saves that player's base scale by UUID. Values must be finite and within current min/max limits, except `1.0` is always allowed. Saved scales follow the usual limits on reconnect and respawn if configuration changes later. A failed save leaves the player's height unchanged. + +Player names must match an online player exactly (case-insensitive); partial names and offline players are rejected. Tab completion suggests online names and valid example sizes. Operators, console, and senders with `spigotheights.admin` may view or set heights, including their own. ## Build diff --git a/design/log.md b/design/log.md index 1674792..719ac42 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting Spigot Heights # Spigot Heights Design Log +## 2026-09-07T00:00:54Z — Player-height commands completed + +- Added `/heights player [scale]` for exact online-player lookup, base/effective scale reporting, immediate saved changes, and permission-protected name/size completion. +- Command-set scales honor live limits and the exact `1.0` restoration exception. The player-state store now publishes cached changes only after successful persistence, and commands save before applying scale. +- Verified `./gradlew clean check jar`: all 33 tests passed, including permissions, input validation, completions, scale reporting, reload, and failed-save protection. Inspected Bukkit lookup/attribute and join/respawn wiring; live-server/client verification remains unperformed. +- Updated README and completed US-008 and related US-004/US-007 criteria. + +## 2026-09-06T23:54:36Z — Player-height commands approved and started + +- Approved [US-008](user-stories/us-008-manage-player-height.md): `/heights player [scale]` for online players, admin permissions, tab completion, and UUID persistence. +- Values follow current min/max bounds with the exact `1.0` exception; failed saves must not change cached or live player height. + ## 2026-09-06T23:51:22Z — Settings commands completed - Added `/heights settings` and `/heights set min|max ` with `spigotheights.admin` (default op), generic sender support, and prefix-filtered tab completion of valid example values. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 591a0c1..70fbd07 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -13,3 +13,4 @@ description: Catalog of user stories for the Spigot Heights plugin. 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) +8. [US-008: View and set player height](us-008-manage-player-height.md) diff --git a/design/user-stories/us-004-configure-and-persist.md b/design/user-stories/us-004-configure-and-persist.md index a9a62a3..e95c6d9 100644 --- a/design/user-stories/us-004-configure-and-persist.md +++ b/design/user-stories/us-004-configure-and-persist.md @@ -21,8 +21,12 @@ As a **server operator**, I want validated stature and launcher settings with du - [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. +- [x] Command-set player heights persist by UUID under existing reload rules; failed saves do not publish a new cached or live height. + ## Related +- [US-008: View and set player height](us-008-manage-player-height.md) + - [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) diff --git a/design/user-stories/us-007-manage-settings-commands.md b/design/user-stories/us-007-manage-settings-commands.md index 62d7d04..05cf67a 100644 --- a/design/user-stories/us-007-manage-settings-commands.md +++ b/design/user-stories/us-007-manage-settings-commands.md @@ -19,12 +19,18 @@ As a **server administrator**, I want commands with tab completion so that I can - [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. +- [x] Command help and completion include the player-height subcommand without changing existing min/max command behavior. + ## Verification +- Player-command extension verified with `./gradlew clean check jar` (33 passing tests), including existing settings-command regressions and new player-name/size completions. + - `./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-008: View and set player height](us-008-manage-player-height.md) + - [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) - [US-006: Restore default stature](us-006-restore-default-stature.md) diff --git a/design/user-stories/us-008-manage-player-height.md b/design/user-stories/us-008-manage-player-height.md new file mode 100644 index 0000000..bcadd8e --- /dev/null +++ b/design/user-stories/us-008-manage-player-height.md @@ -0,0 +1,30 @@ +--- +type: User Story +title: "US-008: View and set player height" +description: Let administrators inspect and persistently set online player scales through tab-completed commands. +status: done +--- + +# US-008: View and set player height + +As a **server administrator**, I want to view and set a player's height so that I can manage individual stature without potions. + +## Acceptance criteria + +- [x] `/heights player ` reports the online player's scale, where `1.0` is normal size. +- [x] `/heights player ` immediately applies and saves the scale by UUID for reconnects and respawns. +- [x] Operators and senders with `spigotheights.admin` can use the commands, including console and targeting themselves. +- [x] Tab completion suggests online player names and valid example sizes, filtered by prefix; unauthorized senders receive no suggestions. +- [x] Values must be finite and within current configured limits, except exact `1.0` is always allowed as the restoration exception. +- [x] Invalid input, unknown/offline players, and persistence failures produce clear messages; failed saves do not change live or cached player height. +- [x] Automated tests and README documentation cover viewing, setting, permissions, completion, validation, and persistence. + +## Verification + +- `./gradlew clean check jar` passed all 33 tests. Coverage includes command routing and permissions, completion, base/effective scale reporting, live limits, the restoration exception, UUID reload, and failed-save protection for live/cached/disk state. +- Code inspection confirms exact online lookup, scale-attribute access, shared storage with join/respawn handlers, and command registration. No live-server/client test was performed. + +## Related + +- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md) +- [US-007: Manage stature settings with commands](us-007-manage-settings-commands.md) diff --git a/src/main/java/games/dmg/spigotheights/BukkitPlayerHeights.java b/src/main/java/games/dmg/spigotheights/BukkitPlayerHeights.java new file mode 100644 index 0000000..7ccf277 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/BukkitPlayerHeights.java @@ -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 names() { + return server.getOnlinePlayers().stream().map(Player::getName) + .sorted(String.CASE_INSENSITIVE_ORDER).toList(); + } +} diff --git a/src/main/java/games/dmg/spigotheights/HeightStore.java b/src/main/java/games/dmg/spigotheights/HeightStore.java index 5a279e4..8e05358 100644 --- a/src/main/java/games/dmg/spigotheights/HeightStore.java +++ b/src/main/java/games/dmg/spigotheights/HeightStore.java @@ -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) { diff --git a/src/main/java/games/dmg/spigotheights/HeightsCommand.java b/src/main/java/games/dmg/spigotheights/HeightsCommand.java index 0600c55..76c3978 100644 --- a/src/main/java/games/dmg/spigotheights/HeightsCommand.java +++ b/src/main/java/games/dmg/spigotheights/HeightsCommand.java @@ -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 "; + private static final String USAGE = "Usage: /heights settings | /heights set min|max " + + " | /heights player [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 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 matching(Stream 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(); } } diff --git a/src/main/java/games/dmg/spigotheights/PlayerHeights.java b/src/main/java/games/dmg/spigotheights/PlayerHeights.java new file mode 100644 index 0000000..9cb90e7 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/PlayerHeights.java @@ -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 settings; + private final HeightStore store; + private final Function lookup; + private final Supplier> onlineNames; + + public PlayerHeights(Supplier settings, HeightStore store, + Function lookup, Supplier> 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 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; + } +} diff --git a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java index 9898fc7..a7455b5 100644 --- a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java +++ b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java @@ -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( diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7c8e6cf..73de37e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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 + description: View and change stature limits and online player heights. + usage: /heights settings | /heights set min|max | /heights player [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 diff --git a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java index 13bad4c..02fa346 100644 --- a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java @@ -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(); diff --git a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java index 64656e3..d3c0aaa 100644 --- a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java @@ -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 saved = new ArrayList<>(); private final List 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()); diff --git a/src/test/java/games/dmg/spigotheights/PlayerHeightsTest.java b/src/test/java/games/dmg/spigotheights/PlayerHeightsTest.java new file mode 100644 index 0000000..f757fb4 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/PlayerHeightsTest.java @@ -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)); + } +}