Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82415ed501 | ||
|
|
f011410b17 | ||
|
|
e802319f45 |
@@ -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`.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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.
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user