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
+11 -1
View File
@@ -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 <name>` 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 <name> <scale>` 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
+12
View File
@@ -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 <name> [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 <name> [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 <value>` with `spigotheights.admin` (default op), generic sender support, and prefix-filtered tab completion of valid example values.
+1
View File
@@ -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)
@@ -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)
@@ -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)
@@ -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 <name>` reports the online player's scale, where `1.0` is normal size.
- [x] `/heights player <name> <scale>` 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)
@@ -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));
}
}