3 Commits
Author SHA1 Message Date
dmg 82415ed501 chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 1m21s
Release / release (push) Successful in 2m3s
2026-09-09 23:18:13 -04:00
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
26 changed files with 759 additions and 249 deletions
+5 -14
View File
@@ -1,18 +1,9 @@
# Spigot Heights Agent Guide # spigot-heights agent entrypoint
## Canonical design The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
- `design/` is the canonical OKF v0.1 knowledge bundle. Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-heights/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-heights/` stories. Also follow the parent workspace `AGENTS.md` when present.
- Read relevant user stories before implementation and keep acceptance criteria and `design/log.md` synchronized with verified behavior.
## Engineering For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
- Target Java 25 and Purpur `26.2.build.2618-stable`. Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
- Use Gradle Kotlin DSL and JUnit 5.
- Prefer small, server-independent domain classes with Bukkit adapters at the boundary.
- Develop test-first where practical and verify with `./gradlew clean check jar`.
- Treat custom item identity as persistent metadata; never trust display names.
- Validate all configuration before enabling gameplay behavior.
- Persist player data by UUID and use atomic replacement where supported.
- Keep event handlers on the server thread and avoid unnecessary work on player movement.
- Use conventional commits in the form `type(scope): description`.
+27 -1
View File
@@ -2,7 +2,7 @@
A Purpur 26.2 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players. A Purpur 26.2 plugin adding craftable player-scaling potions and dispenser launch tubes for tiny players.
Approved behavior is specified in the [OKF design bundle](design/index.md). Approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-heights/index.md).
## Requirements ## Requirements
@@ -64,6 +64,32 @@ launcher:
Player scales are stored by UUID in `plugins/SpigotHeights/state.yml`. 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 ## Build
```bash ```bash
-15
View File
@@ -1,15 +0,0 @@
---
type: Index
title: Spigot Heights Design
description: Entry point for the Spigot Heights OKF knowledge bundle.
okf_version: "0.1"
---
# Spigot Heights Design
This bundle documents craftable player-scaling potions, configurable stature limits, tiny-player dispenser launchers, persistence, and delivery requirements.
## Explore
- [User stories](user-stories/index.md)
- [Design log](log.md)
-40
View File
@@ -1,40 +0,0 @@
---
type: Log
title: Spigot Heights Design Log
description: Chronological record of material decisions affecting Spigot Heights.
---
# Spigot Heights Design Log
## 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`.
- A moderate recipe creates a random stature potion; more expensive upgrades create targeted growth and diminution potions.
- Random stature is selected uniformly in configurable `0.1` increments.
- Players strictly below scale `0.5` can be launched through a dispenser fed by the hopper beneath them.
- Launcher speed defaults to 1.5 blocks per tick with a 20-tick cooldown and safe-exit checks.
- The project follows the neighboring Spigot Base Java 25, Purpur, Gradle, OKF, CI, and release conventions.
## 2026-09-04 — Implementation started
- Approved implementation began with user stories, tests, and the Gradle/Purpur foundation.
## 2026-09-04 — Initial release scope completed
- Implemented authenticated random, growth, and diminution potions with approved recipes and durable UUID-keyed player scale.
- Added validated stature and launcher configuration with safe clamping to Minecraft's scale range.
- Implemented cooldown-protected hopper-to-dispenser launch tubes for tiny players with six-axis launch support and empty-exit checks.
- Added the Java 25 Purpur build, automated tests, Gitea CI and semantic-release workflows, README, and project-specific agent guidance.
- Verified the implementation with `./gradlew clean check jar` and created the public `dmg/spigot-heights` Gitea repository.
-14
View File
@@ -1,14 +0,0 @@
---
type: Index
title: Spigot Heights User Stories
description: Catalog of user stories for the Spigot Heights plugin.
---
# Spigot Heights User Stories
1. [US-001: Drink a Potion of Shifting Stature](us-001-drink-shifting-stature-potion.md)
2. [US-002: Make precise stature adjustments](us-002-adjust-stature.md)
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)
@@ -1,25 +0,0 @@
---
type: User Story
title: "US-001: Drink a Potion of Shifting Stature"
description: Let players craft and drink a potion that gives them a random configured scale.
status: done
---
# US-001: Drink a Potion of Shifting Stature
As a **player**, I want to drink a craftable potion that changes my stature unpredictably so that player size becomes a fun survival mechanic.
## Acceptance criteria
- [x] The distinct potion is authenticated with persistent item metadata rather than its display name alone.
- [x] Its shaped recipe is `ACA/AWA/ACA`, where `A` is Amethyst Shard, `C` is Chorus Fruit, and `W` is an Awkward Potion.
- [x] Drinking it consumes one potion, leaves normal bottle handling intact, and selects a scale from the configured inclusive range.
- [x] Outcomes are uniformly selected in configured adjustment-step increments, including both endpoints when aligned.
- [x] The resulting Minecraft scale attribute is applied and reported to the player.
- [x] The resulting scale persists across logout, restart, world change, and death.
- [x] Automated tests cover bounds, endpoint reachability, and recipe identity.
## 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)
@@ -1,24 +0,0 @@
---
type: User Story
title: "US-002: Make precise stature adjustments"
description: Let players craft upgraded potions that increase or decrease scale by one configured step.
status: done
---
# US-002: Make precise stature adjustments
As a **player**, I want more expensive growth and diminution potions so that I can adjust my stature predictably.
## Acceptance criteria
- [x] The Potion of Growth uses `GAG/ASA/GRG`, where `G` is Gold Ingot, `A` is Amethyst Shard, `S` is an authenticated Potion of Shifting Stature, and `R` is Rabbit's Foot.
- [x] The Potion of Diminution uses `GAG/ASA/GFG`, where `F` is Fermented Spider Eye.
- [x] Each resulting potion has distinct persistent metadata.
- [x] Growth adds one configured adjustment step and clamps at the maximum.
- [x] Diminution subtracts one configured adjustment step and clamps at the minimum.
- [x] Adjusted scales are reported and persisted under the same rules as random stature.
- [x] Automated tests cover adjustment, clamping, and potion identity.
## Related
- [US-001: Drink a Potion of Shifting Stature](us-001-drink-shifting-stature-potion.md)
@@ -1,25 +0,0 @@
---
type: User Story
title: "US-003: Launch tiny players through dispensers"
description: Launch sufficiently small players from a dispenser connected to the hopper beneath them.
status: done
---
# US-003: Launch tiny players through dispensers
As a **tiny player**, I want connected hoppers and dispensers to act as launch tubes so that my stature enables playful transport systems.
## Acceptance criteria
- [x] A player strictly below the configured threshold triggers when walking onto a hopper whose output points into a dispenser.
- [x] No redstone signal is required.
- [x] The player moves to a safe centered position immediately in front of the dispenser and receives velocity in its facing direction.
- [x] Velocity magnitude and cooldown are configurable and default to 1.5 blocks per tick and 20 ticks.
- [x] Obstructed or unsafe exits abort without moving the player.
- [x] Players at or above the threshold are not launched.
- [x] Cooldown prevents immediate repeated or cyclic launching.
- [x] Automated tests cover threshold boundaries, orientations, cooldown, and obstruction policy.
## Related
- [US-004: Configure and persist stature behavior](us-004-configure-and-persist.md)
@@ -1,26 +0,0 @@
---
type: User Story
title: "US-004: Configure and persist stature behavior"
description: Give operators validated settings and durable UUID-keyed player scales.
status: done
---
# US-004: Configure and persist stature behavior
As a **server operator**, I want validated stature and launcher settings with durable state so that behavior remains safe and predictable.
## Acceptance criteria
- [x] Defaults are minimum `0.4`, maximum `2.0`, adjustment step `0.1`, launcher threshold `0.5`, speed `1.5`, and cooldown `20` ticks.
- [x] Configuration requires finite positive values, minimum no greater than maximum, and a launcher threshold within the supported scale range.
- [x] Invalid startup configuration disables the plugin with a clear error.
- [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, except exact scale `1.0`, which is preserved for restoration.
- [x] Configuration and state behavior have automated tests.
## Related
- [User-story catalog](index.md)
- [US-006: Restore default stature](us-006-restore-default-stature.md)
@@ -1,24 +0,0 @@
---
type: User Story
title: "US-005: Build and release the plugin"
description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
status: done
---
# US-005: Build and release the plugin
As a **plugin maintainer**, I want automated builds and releases modeled on Spigot Base so that tested artifacts can be distributed consistently.
## Acceptance criteria
- [x] Gradle compiles against Purpur API `26.2.build.2618-stable` using Java 25.
- [x] Compiler lint warnings fail the build and JUnit 5 tests run during `check`.
- [x] Gitea Actions verifies pushes and pull requests and stores a development JAR.
- [x] Pull requests validate conventional commits.
- [x] Main-branch conventional commits drive semantic releases and attach versioned JARs to Gitea releases.
- [x] The README documents requirements, recipes, configuration, building, and releases.
- [x] `./gradlew clean check jar` succeeds.
## Related
- [User-story catalog](index.md)
@@ -1,28 +0,0 @@
---
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,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.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.UUID; import java.util.UUID;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
public final class HeightStore { public final class HeightStore {
private final Path statePath; private final Path statePath;
private final YamlConfiguration state; private YamlConfiguration state;
public HeightStore(File dataFolder) { public HeightStore(File dataFolder) {
statePath = dataFolder.toPath().resolve("state.yml"); statePath = dataFolder.toPath().resolve("state.yml");
@@ -25,16 +26,23 @@ public final class HeightStore {
} }
public synchronized void save(UUID playerId, double scale) throws IOException { 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()); Files.createDirectories(statePath.getParent());
Path temporary = statePath.resolveSibling("state.yml.tmp"); Path temporary = statePath.resolveSibling("state.yml.tmp");
Files.writeString(temporary, state.saveToString(), StandardCharsets.UTF_8); Files.writeString(temporary, next.saveToString(), StandardCharsets.UTF_8);
try { try {
Files.move(temporary, statePath, StandardCopyOption.ATOMIC_MOVE, Files.move(temporary, statePath, StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING); StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) { } catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, statePath, StandardCopyOption.REPLACE_EXISTING); Files.move(temporary, statePath, StandardCopyOption.REPLACE_EXISTING);
} }
state = next;
} }
private static String path(UUID playerId) { 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;
}
}
@@ -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; package games.dmg.spigotheights;
import java.util.Objects;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -16,12 +18,25 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
return; 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()); 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); PotionRecipes potions = new PotionRecipes(this);
potions.register(); potions.register();
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new StatureListener(this, settings, store, potions), this); new StatureListener(this, liveSettings, store, potions), this);
getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(settings), this); getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(liveSettings), this);
getLogger().info("Spigot Heights enabled."); getLogger().info("Spigot Heights enabled.");
} }
@@ -2,6 +2,7 @@ package games.dmg.spigotheights;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
import org.bukkit.attribute.Attribute; import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance; import org.bukkit.attribute.AttributeInstance;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -19,11 +20,11 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class StatureListener implements Listener { public final class StatureListener implements Listener {
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final HeightSettings settings; private final Supplier<HeightSettings> settings;
private final HeightStore store; private final HeightStore store;
private final PotionRecipes potions; 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.plugin = plugin;
this.settings = settings; this.settings = settings;
this.store = store; this.store = store;
@@ -50,7 +51,7 @@ public final class StatureListener implements Listener {
} }
Player player = event.getPlayer(); Player player = event.getPlayer();
double current = currentScale(player); double current = currentScale(player);
double scale = kind.scaleAfterDrinking(current, settings, double scale = kind.scaleAfterDrinking(current, settings.get(),
bound -> ThreadLocalRandom.current().nextInt(bound)); bound -> ThreadLocalRandom.current().nextInt(bound));
applyAndSave(player, scale); applyAndSave(player, scale);
player.sendMessage("Your scale is now " + scale + "."); player.sendMessage("Your scale is now " + scale + ".");
@@ -58,14 +59,14 @@ public final class StatureListener implements Listener {
@EventHandler @EventHandler
public void onJoin(PlayerJoinEvent event) { 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 @EventHandler
public void onRespawn(PlayerRespawnEvent event) { public void onRespawn(PlayerRespawnEvent event) {
plugin.getServer().getScheduler().runTask(plugin, () -> { plugin.getServer().getScheduler().runTask(plugin, () -> {
Player player = event.getPlayer(); Player player = event.getPlayer();
apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings)); apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings.get()));
}); });
} }
@@ -3,6 +3,7 @@ package games.dmg.spigotheights;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
@@ -19,11 +20,11 @@ import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
public final class TinyPlayerLauncher implements Listener { public final class TinyPlayerLauncher implements Listener {
private final HeightSettings settings; private final Supplier<HeightSettings> settingsSupplier;
private final Map<UUID, Long> lastLaunchTicks = new HashMap<>(); private final Map<UUID, Long> lastLaunchTicks = new HashMap<>();
public TinyPlayerLauncher(HeightSettings settings) { public TinyPlayerLauncher(Supplier<HeightSettings> settingsSupplier) {
this.settings = settings; this.settingsSupplier = settingsSupplier;
} }
@EventHandler(ignoreCancelled = true) @EventHandler(ignoreCancelled = true)
@@ -32,6 +33,7 @@ public final class TinyPlayerLauncher implements Listener {
if (destination == null || sameBlock(event.getFrom(), destination)) { if (destination == null || sameBlock(event.getFrom(), destination)) {
return; return;
} }
HeightSettings settings = settingsSupplier.get();
Player player = event.getPlayer(); Player player = event.getPlayer();
if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) { if (!LauncherPolicy.isSmallEnough(scale(player), settings.launcherThreshold())) {
return; return;
+9
View File
@@ -4,3 +4,12 @@ main: games.dmg.spigotheights.SpigotHeightsPlugin
api-version: "1.21" api-version: "1.21"
description: Craftable player stature potions and tiny-player dispenser launchers. description: Craftable player stature potions and tiny-player dispenser launchers.
author: dmg.games 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
@@ -1,6 +1,9 @@
package games.dmg.spigotheights; package games.dmg.spigotheights;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
@@ -12,6 +15,18 @@ class HeightStoreTest {
@TempDir @TempDir
Path temporaryDirectory; 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 @Test
void restorationReplacesPreviousSizeAndSurvivesReloadOutsideLimits() throws Exception { void restorationReplacesPreviousSizeAndSurvivesReloadOutsideLimits() throws Exception {
UUID playerId = UUID.randomUUID(); 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));
}
}
@@ -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));
}
}