Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82415ed501 | ||
|
|
f011410b17 | ||
|
|
e802319f45 | ||
|
|
fd601961eb |
@@ -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.
|
||||
- Read relevant user stories before implementation and keep acceptance criteria and `design/log.md` synchronized with verified behavior.
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
- 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`.
|
||||
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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: Log
|
||||
title: Spigot Heights Design Log
|
||||
description: Chronological record of material decisions affecting Spigot Heights.
|
||||
---
|
||||
|
||||
# Spigot Heights Design Log
|
||||
|
||||
## 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.
|
||||
@@ -1,13 +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)
|
||||
@@ -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,25 +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.
|
||||
- [x] Configuration and state behavior have automated tests.
|
||||
|
||||
## Related
|
||||
|
||||
- [User-story catalog](index.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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user