2 Commits
Author SHA1 Message Date
dmg e802319f45 feat(commands): manage height bounds with tab completion
Release / release (push) Successful in 2m11s
CI / build (push) Successful in 1m32s
2026-09-06 19:53:19 -04:00
dmg fd601961eb feat(potions): add restoration potion for default player size
Release / release (push) Successful in 2m11s
CI / build (push) Successful in 54s
2026-09-06 19:42:17 -04:00
23 changed files with 638 additions and 18 deletions
+26
View File
@@ -33,6 +33,16 @@ Gold Ingot | Rabbit's Foot | Gold Ingot
Use the Growth recipe with a Fermented Spider Eye instead of the Rabbit's Foot. Growth and Diminution adjust scale by one configured step and clamp at the limits.
### Potion of Restoration
```text
Gold Ingot | Amethyst Shard | Gold Ingot
Amethyst Shard | Potion of Shifting Stature | Amethyst Shard
Gold Ingot | Sugar | Gold Ingot
```
Produces one Potion of Restoration. Drinking it restores scale to exactly `1.0` (normal size), even outside configured limits. This size is saved across reconnects, respawns, and server restarts. Other saved scales are still clamped to the configured range.
## Tiny-player launchers
A player below the configured scale threshold can walk onto a hopper whose output points into a dispenser. If the block in front of that dispenser is passable, the player is moved there and launched in the direction the dispenser faces. The launcher does not require redstone.
@@ -54,6 +64,22 @@ launcher:
Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`.
## Commands
Operators (or senders granted `spigotheights.admin`) can use:
```text
/heights settings
/heights set min 0.2
/heights set max 3.0
```
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.
## Build
```bash
+24
View File
@@ -6,6 +6,30 @@ description: Chronological record of material decisions affecting Spigot Heights
# Spigot Heights Design Log
## 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.
- Validated changes are persisted through temporary-file replacement (atomic where supported) before activating shared live settings. Unrelated YAML values are preserved; malformed files and write failures do not activate new limits.
- Updated gameplay listeners to read current settings without immediately resizing players or resetting launcher cooldowns.
- Verified `./gradlew clean check jar`: all 26 tests passed. Inspected command registration and event wiring; live-server/client verification remains unperformed. Completed US-007 and the related US-004 change.
## 2026-09-06T23:45:28Z — Settings commands approved and started
- Approved [US-007](user-stories/us-007-manage-settings-commands.md): permission-protected min/max commands, tab completion, validation, and persistent live updates.
- Existing players are not resized immediately; subsequent gameplay reads current limits. The exact `1.0` restoration exception remains intact.
## 2026-09-06T23:41:16Z — Restoration completed
- Added the white Potion of Restoration with persistent identity and the approved Sugar upgrade recipe.
- Consumption restores exact scale `1.0` through the existing report/save path; saved `1.0` bypasses configured-range clamping on join and respawn.
- Verified `./gradlew clean check jar`: all 17 tests passed, including restoration, persistence reload, range exceptions, recipe shape, identity, and existing potion regression coverage.
- Inspected Bukkit recipe/metadata and event wiring; live-server gameplay verification remains unperformed. Updated the README and completed US-006 and the related US-004 change.
## 2026-09-06T23:37:19Z — Restoration approved and implementation started
- Approved [US-006](user-stories/us-006-restore-default-stature.md): a Sugar-based upgrade of Shifting Stature restores exact scale `1.0`.
- Approved an exception to saved-scale clamping in [US-004](user-stories/us-004-configure-and-persist.md) so restoration survives reconnects and respawns even outside configured limits.
## 2026-09-04 — Initial design approved
- Player scale defaults to a configurable range of `0.4` through `2.0`.
+2
View File
@@ -11,3 +11,5 @@ description: Catalog of user stories for the Spigot Heights plugin.
3. [US-003: Launch tiny players through dispensers](us-003-launch-tiny-players.md)
4. [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
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)
@@ -17,9 +17,12 @@ As a **server operator**, I want validated stature and launcher settings with du
- [x] Player scales are stored by UUID using atomic file replacement where supported.
- [x] Updating known state preserves unknown forward-compatible YAML fields.
- [x] Missing state defaults safely to scale `1.0` clamped to the configured range.
- [x] Saved out-of-range state is clamped before it is applied.
- [x] Saved out-of-range state is clamped before it is applied, except exact scale `1.0`, which is preserved for restoration.
- [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.
## Related
- [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)
@@ -0,0 +1,28 @@
---
type: User Story
title: "US-006: Restore default stature"
description: Let players craft a potion that permanently restores their scale to 1.0.
status: done
---
# US-006: Restore default stature
As a **player**, I want a Potion of Restoration so that I can return to normal size.
## Acceptance criteria
- [x] Drinking the potion sets scale to exactly `1.0`, regardless of configured limits.
- [x] The potion uses distinct persistent metadata rather than its display name for identity.
- [x] The new scale is reported and saved by UUID, and retained across reconnects and respawns.
- [x] The recipe is `GAG/ASA/GUG`, where `G` is Gold Ingot, `A` is Amethyst Shard, `S` is an authenticated Potion of Shifting Stature, and `U` is Sugar.
- [x] Automated tests cover restoration, saved-scale handling, recipe shape, and distinct identity; the README documents the recipe and behavior.
## Verification
- `./gradlew clean check jar` passed all 17 tests.
- Automated coverage checks scale calculation, storage reload, range exceptions, recipe shape, and enum identity. Code inspection confirms metadata authentication, ingredient registration, and the shared consumption/join/respawn adapters; no live-server gameplay test was performed.
## Related
- [US-002: Make precise stature adjustments](us-002-adjust-stature.md)
- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
@@ -0,0 +1,30 @@
---
type: User Story
title: "US-007: Manage stature settings with commands"
description: Let administrators view and persistently change stature bounds with tab-completed commands.
status: done
---
# US-007: Manage stature settings with commands
As a **server administrator**, I want commands with tab completion so that I can change stature limits without restarting the server.
## Acceptance criteria
- [x] `/heights settings` displays current settings; `/heights set min|max <value>` changes the selected bound.
- [x] Operators and senders with `spigotheights.admin` may use the commands, including console; unauthorized senders cannot change settings or receive suggestions.
- [x] Tab completion suggests subcommands, `min`/`max`, and valid example values, filtered by the typed prefix.
- [x] Successful changes apply immediately to gameplay and persist to `plugins/SpigotHeights/config.yml` without discarding unrelated configuration.
- [x] Invalid arguments or settings produce clear explanations and leave active and saved settings unchanged; persistence failure does not activate the new settings.
- [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.
## Verification
- `./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-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
- [US-006: Restore default stature](us-006-restore-default-stature.md)
@@ -27,7 +27,8 @@ public final class HeightMath {
if (stored == null || !Double.isFinite(stored)) {
return clamp(1.0, settings);
}
return clamp(stored, settings);
// Restoration is an explicit escape from configured stature limits.
return stored == 1.0 ? 1.0 : clamp(stored, settings);
}
public static double clamp(double value, HeightSettings settings) {
@@ -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;
}
}
@@ -17,6 +17,7 @@ public final class PotionRecipes {
static final String[] SHIFTING_SHAPE = {"ACA", "AWA", "ACA"};
static final String[] GROWTH_SHAPE = {"GAG", "ASA", "GRG"};
static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"};
static final String[] RESTORATION_SHAPE = {"GAG", "ASA", "GUG"};
private final JavaPlugin plugin;
private final NamespacedKey potionKindKey;
@@ -30,6 +31,7 @@ public final class PotionRecipes {
registerShifting();
registerGrowth();
registerDiminution();
registerRestoration();
}
public ItemStack create(StaturePotion kind) {
@@ -96,11 +98,23 @@ public final class PotionRecipes {
plugin.getServer().addRecipe(recipe);
}
private void registerRestoration() {
ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "restoration"),
create(StaturePotion.RESTORATION));
recipe.shape(RESTORATION_SHAPE);
recipe.setIngredient('G', Material.GOLD_INGOT);
recipe.setIngredient('A', Material.AMETHYST_SHARD);
recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING)));
recipe.setIngredient('U', Material.SUGAR);
plugin.getServer().addRecipe(recipe);
}
private static Color color(StaturePotion kind) {
return switch (kind) {
case SHIFTING -> Color.PURPLE;
case GROWTH -> Color.LIME;
case DIMINUTION -> Color.FUCHSIA;
case RESTORATION -> Color.WHITE;
};
}
}
@@ -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,26 +51,22 @@ public final class StatureListener implements Listener {
}
Player player = event.getPlayer();
double current = currentScale(player);
double scale = switch (kind) {
case SHIFTING -> HeightMath.randomScale(settings,
bound -> ThreadLocalRandom.current().nextInt(bound));
case GROWTH -> HeightMath.grow(current, settings);
case DIMINUTION -> HeightMath.shrink(current, settings);
};
double scale = kind.scaleAfterDrinking(current, settings.get(),
bound -> ThreadLocalRandom.current().nextInt(bound));
applyAndSave(player, scale);
player.sendMessage("Your scale is now " + scale + ".");
}
@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()));
});
}
@@ -1,9 +1,12 @@
package games.dmg.spigotheights;
import java.util.function.IntUnaryOperator;
public enum StaturePotion {
SHIFTING("Potion of Shifting Stature"),
GROWTH("Potion of Growth"),
DIMINUTION("Potion of Diminution");
DIMINUTION("Potion of Diminution"),
RESTORATION("Potion of Restoration");
private final String displayName;
@@ -11,6 +14,15 @@ public enum StaturePotion {
this.displayName = displayName;
}
public double scaleAfterDrinking(double current, HeightSettings settings, IntUnaryOperator randomIndex) {
return switch (this) {
case SHIFTING -> HeightMath.randomScale(settings, randomIndex);
case GROWTH -> HeightMath.grow(current, settings);
case DIMINUTION -> HeightMath.shrink(current, settings);
case RESTORATION -> 1.0;
};
}
public String displayName() {
return displayName;
}
@@ -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;
+9
View File
@@ -4,3 +4,12 @@ main: games.dmg.spigotheights.SpigotHeightsPlugin
api-version: "1.21"
description: Craftable player stature potions and tiny-player dispenser launchers.
author: dmg.games
commands:
heights:
description: View and change Spigot Heights stature limits.
usage: /heights settings | /heights set min|max <value>
permission: spigotheights.admin
permissions:
spigotheights.admin:
description: View and change Spigot Heights settings.
default: op
@@ -20,6 +20,20 @@ class HeightMathTest {
assertEquals(1.1, HeightMath.grow(1.0, SETTINGS), 0.000001);
}
@Test
void savedDefaultSizeSurvivesRangesThatExcludeIt() {
for (HeightSettings settings : new HeightSettings[] {
new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20),
new HeightSettings(1.2, 2.0, 0.1, 1.5, 1.5, 20)}) {
assertEquals(1.0, HeightMath.safeStoredScale(1.0, settings));
assertEquals(settings.minimum(), HeightMath.safeStoredScale(0.1, settings));
assertEquals(settings.maximum(), HeightMath.safeStoredScale(3.0, settings));
double fallback = HeightMath.clamp(1.0, settings);
assertEquals(fallback, HeightMath.safeStoredScale(null, settings));
assertEquals(fallback, HeightMath.safeStoredScale(Double.NaN, settings));
}
}
@Test
void missingAndStoredValuesAreSafelyClamped() {
assertEquals(1.0, HeightMath.safeStoredScale(null, SETTINGS), 0.000001);
@@ -12,6 +12,19 @@ class HeightStoreTest {
@TempDir
Path temporaryDirectory;
@Test
void restorationReplacesPreviousSizeAndSurvivesReloadOutsideLimits() throws Exception {
UUID playerId = UUID.randomUUID();
HeightSettings settings = new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20);
HeightStore store = new HeightStore(temporaryDirectory.toFile());
store.save(playerId, 0.4);
store.save(playerId, StaturePotion.RESTORATION.scaleAfterDrinking(0.4, settings, bound -> 0));
HeightStore reloaded = new HeightStore(temporaryDirectory.toFile());
assertEquals(1.0, reloaded.find(playerId));
assertEquals(1.0, HeightMath.safeStoredScale(reloaded.find(playerId), settings));
}
@Test
void storesByUuidAndPreservesUnknownYamlFields() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -0,0 +1,111 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
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());
@Test
void authorizedSenderCanInspectAndChangeBounds() {
CommandSender sender = sender(true);
command.onCommand(sender, null, "heights", new String[] {"settings"});
assertTrue(messages.getLast().contains("min=0.4"));
assertTrue(messages.getLast().contains("max=2.0"));
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
command.onCommand(sender, null, "heights", new String[] {"set", "max", "3"});
assertEquals(0.2, live.get().minimum());
assertEquals(3.0, live.get().maximum());
assertEquals(2, saved.size());
assertTrue(messages.getLast().contains("3.0"));
}
@Test
void unauthorizedSenderCannotInspectMutateOrComplete() {
CommandSender sender = sender(false);
command.onCommand(sender, null, "heights", new String[] {"set", "min", "0.2"});
command.onCommand(sender, null, "heights", new String[] {"settings"});
assertTrue(messages.stream().allMatch(message -> message.contains("permission")));
assertEquals(List.of(), saved);
assertEquals(List.of(), command.onTabComplete(sender, null, "heights", new String[] {""}));
}
@Test
void malformedAndInvalidInputNeverSaves() {
for (String[] args : new String[][] {
{}, {"set"}, {"set", "min"}, {"set", "min", "0.2", "extra"},
{"settings", "extra"}, {"unknown"}, {"set", "speed", "1"},
{"set", "min", "nope"}, {"set", "min", "NaN"},
{"set", "max", "Infinity"}, {"set", "min", "0.6"}, {"set", "max", "0.3"}}) {
int before = messages.size();
assertTrue(command.onCommand(sender(true), null, "heights", args));
assertEquals(before + 1, messages.size());
}
assertEquals(List.of(), saved);
assertEquals(0.4, live.get().minimum());
assertEquals(2.0, live.get().maximum());
}
@Test
void completionsArePrefixFilteredAndValidForCurrentRange() throws Exception {
CommandSender sender = sender(true);
assertEquals(List.of("settings", "set"), complete(sender, "s"));
assertEquals(List.of("settings"), complete(sender, "sett"));
assertEquals(List.of("min", "max"), complete(sender, "set", "m"));
assertEquals(List.of("max"), complete(sender, "set", "ma"));
assertEquals(List.of(), complete(sender, "settings", ""));
assertEquals(List.of(), complete(sender, "set", "other", ""));
assertEquals(List.of(), complete(sender, "set", "min", "0.2", ""));
assertTrue(complete(sender, "set", "min", "0.").contains("0.2"));
for (String bound : List.of("min", "max")) {
for (String value : complete(sender, "set", bound, "")) {
live.candidate(bound, Double.parseDouble(value));
}
}
live.setBound("max", 0.8);
assertTrue(complete(sender, "set", "max", "0.").contains("0.8"));
assertEquals(List.of(), complete(sender, "set", "min", "1"));
}
@Test
void saveFailureIsReportedWithoutChangingActiveSettings() {
LiveHeightSettings failing = new LiveHeightSettings(live.get(), settings -> {
throw new IOException("test save failure");
});
new HeightsCommand(failing, 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());
}
private List<String> complete(CommandSender sender, String... args) {
return command.onTabComplete(sender, null, "heights", args);
}
private CommandSender sender(boolean permitted) {
return (CommandSender) Proxy.newProxyInstance(CommandSender.class.getClassLoader(),
new Class<?>[] {CommandSender.class}, (proxy, method, args) -> {
if (method.getName().equals("hasPermission")) {
assertEquals("spigotheights.admin", args[0]);
return permitted;
}
if (method.getName().equals("sendMessage") && args[0] instanceof String text) {
messages.add(text);
return null;
}
throw new UnsupportedOperationException(method.getName());
});
}
}
@@ -0,0 +1,50 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class LiveHeightSettingsTest {
private static final HeightSettings DEFAULTS = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
@Test
void savesBeforePublishingAndGameplayReadsNewBounds() throws Exception {
List<HeightSettings> saved = new ArrayList<>();
LiveHeightSettings live = new LiveHeightSettings(DEFAULTS, saved::add);
live.setBound("min", 0.2);
live.setBound("max", 3.0);
assertEquals(2, saved.size());
assertSame(saved.getLast(), live.get());
assertEquals(0.2, HeightMath.randomScale(live.get(), bound -> 0));
assertEquals(3.0, HeightMath.grow(3.0, live.get()));
assertEquals(0.2, HeightMath.safeStoredScale(0.1, live.get()));
assertEquals(1.0, HeightMath.safeStoredScale(1.0, live.get()));
assertEquals(0.1, live.get().adjustmentStep());
assertEquals(0.5, live.get().launcherThreshold());
}
@Test
void invalidChangesAndSaveFailuresDoNotPublish() {
List<HeightSettings> saved = new ArrayList<>();
LiveHeightSettings live = new LiveHeightSettings(DEFAULTS, saved::add);
for (double value : new double[] {Double.NaN, Double.POSITIVE_INFINITY, -1, 0, 0.01, 0.6, 3}) {
assertThrows(IllegalArgumentException.class, () -> live.setBound("min", value));
}
for (double value : new double[] {0.3, 0.45, 17, Double.NEGATIVE_INFINITY}) {
assertThrows(IllegalArgumentException.class, () -> live.setBound("max", value));
}
assertThrows(IllegalArgumentException.class, () -> live.setBound("speed", 1));
assertEquals(List.of(), saved);
assertSame(DEFAULTS, live.get());
LiveHeightSettings failing = new LiveHeightSettings(DEFAULTS, settings -> {
throw new IOException("disk full");
});
assertThrows(IOException.class, () -> failing.setBound("min", 0.2));
assertSame(DEFAULTS, failing.get());
}
}
@@ -2,6 +2,7 @@ package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
@@ -13,6 +14,17 @@ class PotionRecipesTest {
assertArrayEquals(new String[] {"GAG", "ASA", "GFG"}, PotionRecipes.DIMINUTION_SHAPE);
}
@Test
void restorationUsesApprovedShapeAndDistinctIdentity() {
assertArrayEquals(new String[] {"GAG", "ASA", "GUG"}, PotionRecipes.RESTORATION_SHAPE);
assertEquals("RESTORATION", StaturePotion.RESTORATION.name());
for (StaturePotion kind : StaturePotion.values()) {
if (kind != StaturePotion.RESTORATION) {
assertNotEquals(kind.name(), StaturePotion.RESTORATION.name());
}
}
}
@Test
void everyPotionHasASeparatePersistentIdentity() {
assertNotEquals(StaturePotion.SHIFTING.name(), StaturePotion.GROWTH.name());
@@ -0,0 +1,47 @@
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;
import org.bukkit.configuration.file.YamlConfiguration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SettingsFileStoreTest {
@TempDir
Path directory;
@Test
void savedBoundsSurviveReloadAndPreserveOtherConfiguration() throws Exception {
Path config = directory.resolve("config.yml");
try (var defaults = getClass().getResourceAsStream("/config.yml")) {
Files.copy(defaults, config);
}
Files.writeString(config, Files.readString(config) + "\nfuture-setting: retained\n");
HeightSettings initial = SpigotHeightsPlugin.loadSettings(YamlConfiguration.loadConfiguration(config.toFile()));
LiveHeightSettings live = new LiveHeightSettings(initial, new SettingsFileStore(config)::save);
live.setBound("min", 0.2);
live.setBound("max", 3.0);
YamlConfiguration reloaded = YamlConfiguration.loadConfiguration(config.toFile());
assertEquals(live.get(), SpigotHeightsPlugin.loadSettings(reloaded));
assertEquals("retained", reloaded.getString("future-setting"));
try (var files = Files.list(directory)) {
assertEquals(1, files.count());
}
}
@Test
void malformedConfigurationIsNotOverwrittenOrActivated() throws Exception {
Path config = directory.resolve("config.yml");
String malformed = "height: [unterminated";
Files.writeString(config, malformed);
HeightSettings initial = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
LiveHeightSettings live = new LiveHeightSettings(initial, new SettingsFileStore(config)::save);
assertThrows(IOException.class, () -> live.setBound("min", 0.2));
assertEquals(initial, live.get());
assertEquals(malformed, Files.readString(config));
}
}
@@ -0,0 +1,32 @@
package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class StaturePotionTest {
@Test
void existingPotionsKeepTheirScaleRules() {
HeightSettings settings = new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20);
assertEquals(0.4, StaturePotion.SHIFTING.scaleAfterDrinking(1.0, settings, bound -> 0));
assertEquals(2.0, StaturePotion.SHIFTING.scaleAfterDrinking(1.0, settings, bound -> bound - 1));
assertEquals(1.1, StaturePotion.GROWTH.scaleAfterDrinking(1.0, settings, bound -> 0));
assertEquals(0.9, StaturePotion.DIMINUTION.scaleAfterDrinking(1.0, settings, bound -> 0));
assertEquals(2.0, StaturePotion.GROWTH.scaleAfterDrinking(2.0, settings, bound -> 0));
assertEquals(0.4, StaturePotion.DIMINUTION.scaleAfterDrinking(0.4, settings, bound -> 0));
}
@Test
void restorationAlwaysReturnsNormalSizeWithoutRandomness() {
for (HeightSettings settings : new HeightSettings[] {
new HeightSettings(0.4, 2.0, 0.1, 0.5, 1.5, 20),
new HeightSettings(0.2, 0.8, 0.1, 0.5, 1.5, 20),
new HeightSettings(1.2, 2.0, 0.1, 1.5, 1.5, 20)}) {
for (double current : new double[] {0.2, 1.0, 2.0}) {
assertEquals(1.0, StaturePotion.RESTORATION.scaleAfterDrinking(current, settings,
bound -> { throw new AssertionError("Restoration must not roll a random size"); }));
}
}
assertEquals("Potion of Restoration", StaturePotion.RESTORATION.displayName());
}
}