3 Commits
Author SHA1 Message Date
dmg f011410b17 feat(commands): view and set online player heights
Release / release (push) Successful in 2m54s
CI / build (push) Successful in 1m2s
2026-09-06 20:05:41 -04:00
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
28 changed files with 1005 additions and 21 deletions
+36
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,32 @@ 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
/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`. 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
```bash
+36
View File
@@ -6,6 +6,42 @@ 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.
- 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`.
+3
View File
@@ -11,3 +11,6 @@ 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)
8. [US-008: View and set player height](us-008-manage-player-height.md)
@@ -17,9 +17,16 @@ 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.
- [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)
@@ -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,36 @@
---
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.
- [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();
}
}
@@ -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) {
@@ -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) {
@@ -0,0 +1,131 @@
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>"
+ " | /heights player <name> [scale]";
private final LiveHeightSettings settings;
private final Logger logger;
private final PlayerHeights players;
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.");
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()
+ ", 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;
}
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", "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]);
}
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.toLowerCase(Locale.ROOT).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;
}
}
@@ -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;
}
}
@@ -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,25 @@ 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());
});
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);
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 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 and player heights.
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);
@@ -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,31 @@ 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();
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,176 @@
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.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);
@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() {
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"});
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[] {""}));
}
@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, 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());
}
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());
}
}
@@ -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));
}
}
@@ -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());
}
}