From 47d5060dacdce14ca31e90b43c291b2f99a2526a Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 10 Sep 2026 22:41:20 -0400 Subject: [PATCH] feat(heights): add durable temporary throwable stature --- README.md | 20 +- .../spigotheights/BukkitStatureService.java | 211 ++++++++++++++++++ .../dmg/spigotheights/CompletionMailbox.java | 27 +++ .../games/dmg/spigotheights/HeightStore.java | 85 ++++++- .../dmg/spigotheights/HeightsCommand.java | 18 +- .../dmg/spigotheights/PotionRecipes.java | 81 +++++-- .../spigotheights/SpigotHeightsPlugin.java | 28 ++- .../dmg/spigotheights/StatureClouds.java | 69 ++++++ .../dmg/spigotheights/StatureListener.java | 68 ++---- .../games/dmg/spigotheights/StatureQueue.java | 77 +++++++ .../dmg/spigotheights/TemporaryStature.java | 64 ++++++ .../ThrowableStatureListener.java | 151 +++++++++++++ .../dmg/spigotheights/BukkitDoubles.java | 65 ++++++ .../spigotheights/CompletionMailboxTest.java | 22 ++ .../dmg/spigotheights/HeightStoreTest.java | 17 ++ .../dmg/spigotheights/HeightsCommandTest.java | 19 ++ .../dmg/spigotheights/PotionRecipesTest.java | 63 ++++++ .../dmg/spigotheights/StatureCloudsTest.java | 36 +++ .../spigotheights/StatureListenerTest.java | 32 +++ .../dmg/spigotheights/StatureQueueTest.java | 68 ++++++ .../spigotheights/TemporaryStatureTest.java | 127 +++++++++++ .../ThrowableStatureListenerTest.java | 161 +++++++++++++ 22 files changed, 1434 insertions(+), 75 deletions(-) create mode 100644 src/main/java/games/dmg/spigotheights/BukkitStatureService.java create mode 100644 src/main/java/games/dmg/spigotheights/CompletionMailbox.java create mode 100644 src/main/java/games/dmg/spigotheights/StatureClouds.java create mode 100644 src/main/java/games/dmg/spigotheights/StatureQueue.java create mode 100644 src/main/java/games/dmg/spigotheights/TemporaryStature.java create mode 100644 src/main/java/games/dmg/spigotheights/ThrowableStatureListener.java create mode 100644 src/test/java/games/dmg/spigotheights/BukkitDoubles.java create mode 100644 src/test/java/games/dmg/spigotheights/CompletionMailboxTest.java create mode 100644 src/test/java/games/dmg/spigotheights/StatureCloudsTest.java create mode 100644 src/test/java/games/dmg/spigotheights/StatureListenerTest.java create mode 100644 src/test/java/games/dmg/spigotheights/StatureQueueTest.java create mode 100644 src/test/java/games/dmg/spigotheights/TemporaryStatureTest.java create mode 100644 src/test/java/games/dmg/spigotheights/ThrowableStatureListenerTest.java diff --git a/README.md b/README.md index 39fd73a..5a0fa4b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,21 @@ 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. +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 permanent saved scales are still clamped to the configured range. + +### Temporary splash and lingering variants + +Brew any of the four authenticated stature potions with **gunpowder** to make its splash variant; brew that with **dragon's breath** to make its lingering variant. Brewing preserves the stature kind, not arbitrary cosmetic renames/lore. Ordinary potions and cancelled brewing/application events are not overridden. Renaming an ordinary item cannot turn it into a stature potion. + +Each splash/cloud applies once per player, without recipient opt-in. The first application saves the player's original base scale. Further applications operate on the temporary scale and reset expiry to **five minutes after the latest application**, including a valid application already at a stature bound. Growth/shrinking respect the current limits; throwable Restoration sets temporary scale to exactly `1.0`. + +Example: `1.0 → 0.8`, then `0.8 → 0.6` four minutes later, returns to `1.0` five minutes after the second application. Expiry restores the **exact original baseline**, even if administrator limits changed meanwhile. Offline time counts. Baseline, expiry, and application receipts are persisted; a pending restoration survives disconnect during persistence and is acknowledged only after live application. After that sequence is cleared, ordinary later joins/respawns use the usual limit policy. + +Drinking a permanent stature potion ends the temporary sequence, using the current temporary scale (or restored baseline if already expired). Administrator height changes also end the sequence. File writes are serialized off the server tick thread; failed saves do not apply an unsaved change. Disable waits for the active writer and drains its result before returning. Do not forcibly kill the server while it is saving. + +Heights tracks only its loaded clouds and checks them every five ticks, dispatching `AreaEffectCloudApplyEvent` and honoring cancellation and recipient filtering. This supports effectless stature clouds without adding a vanilla status effect. Online expiry is checked every second. Durable receipts survive cloud unload and are retired on observed permanent entity removal; abrupt process termination can leave harmless orphan receipts. + +**Verification:** automated domain and Bukkit-boundary tests cover this implementation. Actual brewing, cloud delivery, client appearance, and logout/restart gameplay on Purpur 26.2 build 2618 still require manual acceptance checks. ## Tiny-player launchers @@ -80,13 +94,13 @@ Console is supported. Tab completion suggests subcommands, bound names, and vali 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. +Players are not immediately resized. Subsequent potion use, joins, and respawns use the new limits; Restoration still returns and preserves exact scale `1.0`. Processing an expired temporary sequence is another exception: its exact baseline is restored before ordinary later joins/respawns follow the new limits. The settings commands only change min/max; edit other settings in the configuration file and restart the server. ### Player height `/heights player ` 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 ` 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. +`/heights player ` sets that player's base scale by UUID after an asynchronous durable save and ends any temporary sequence. 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. diff --git a/src/main/java/games/dmg/spigotheights/BukkitStatureService.java b/src/main/java/games/dmg/spigotheights/BukkitStatureService.java new file mode 100644 index 0000000..264e347 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/BukkitStatureService.java @@ -0,0 +1,211 @@ +package games.dmg.spigotheights; + +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +/** Captures Bukkit state on the tick thread; all stature file writes run on one worker. */ +public final class BukkitStatureService implements AutoCloseable { + private final JavaPlugin plugin; + private final Supplier settings; + private final HeightStore store; + private final TemporaryStature stature; + private final ExecutorService worker = Executors.newSingleThreadExecutor(Thread.ofPlatform() + .name("heights-state").factory()); + private final StatureQueue queue; + private final CompletionMailbox completions; + private final Set expiring = new HashSet<>(); + + public BukkitStatureService(JavaPlugin plugin, Supplier settings, HeightStore store) { + this.plugin = plugin; + this.settings = settings; + this.store = store; + this.stature = new TemporaryStature(store); + completions = new CompletionMailbox(task -> { + if (plugin.isEnabled()) { + try { + plugin.getServer().getScheduler().runTask(plugin, task); + } catch (org.bukkit.plugin.IllegalPluginAccessException ignored) { + // Disable raced with a completed, already durable write. Login will resume it. + } + } + }); + queue = new StatureQueue(worker, completions); + } + + public void temporary(UUID source, Player player, StaturePotion kind, Consumer completed) { + change(player, kind, source, completed); + } + + public void drink(Player player, StaturePotion kind) { + change(player, kind, null, success -> {}); + } + + private void change(Player player, StaturePotion kind, UUID source, Consumer completed) { + UUID id = player.getUniqueId(); + queue.submit(() -> { + Player currentPlayer = requireOnline(id); + double current = scale(currentPlayer); + HeightSettings limits = settings.get(); + Instant now = Instant.now(); + return () -> { + // A duplicate returns null; do not unbox it through a mixed Double/double ternary. + if (source != null) { + return stature.applyOnce(id, source, current, kind, limits, now, + bound -> ThreadLocalRandom.current().nextInt(bound)); + } + return stature.drink(id, current, kind, limits, now, + bound -> ThreadLocalRandom.current().nextInt(bound)); + }; + }, (result, error) -> { + if (error == null) { + if (result != null) { + applyOnline(id, result); + } + completed.accept(true); + } else { + report(id, error); + completed.accept(false); + } + }); + } + + public void resume(Player player) { + UUID id = player.getUniqueId(); + queue.submit(() -> { + requireOnline(id); + HeightSettings limits = settings.get(); + Instant now = Instant.now(); + return () -> stature.resume(id, limits, now); + }, (result, error) -> { + if (error == null) { applyRestoration(id, result); } else { report(id, error); } + }); + } + + /** Called once a second, not on movement; reads immutable cached state only. */ + public void expireOnline() { + Instant now = Instant.now(); + for (Player player : plugin.getServer().getOnlinePlayers()) { + UUID id = player.getUniqueId(); + HeightStore.Sequence sequence = store.sequence(id); + if (player.isDead() || sequence == null || now.isBefore(sequence.expiresAt()) || !expiring.add(id)) { + continue; + } + queue.submit(() -> { + HeightSettings limits = settings.get(); + Instant at = Instant.now(); + // Recheck at queue execution: a permanent override or a later splash may have won. + Callable work = () -> { + HeightStore.Sequence latest = store.sequence(id); + return latest != null && !at.isBefore(latest.expiresAt()) ? stature.resume(id, limits, at) : null; + }; + return work; + }, (result, error) -> { + expiring.remove(id); + if (error != null) { report(id, error); } + else if (result != null) { applyRestoration(id, result); } + }); + } + } + + public void retireSource(UUID source) { + queue.submit(() -> () -> { store.forgetSource(source); return null; }, (result, error) -> { + if (error != null) { + plugin.getLogger().warning("Could not retire throwable receipt " + source + ": " + error.getMessage()); + } + }); + } + + public void set(String name, double value, Consumer reply) { + Player target = plugin.getServer().getPlayerExact(name); + if (target == null) { + reply.accept("No online player named '" + name + "'. Use their full name."); + return; + } + UUID id = target.getUniqueId(); + queue.submit(() -> { + requireOnline(id); + HeightSettings limits = settings.get(); + if (!Double.isFinite(value) || (value != 1.0 && (value < limits.minimum() || value > limits.maximum()))) { + throw new IllegalArgumentException("Scale must be finite and within current limits, or exactly 1.0."); + } + return () -> { store.save(id, value); return value; }; + }, (result, error) -> { + if (error == null) { + applyOnline(id, result); + reply.accept("Set " + name + "'s base scale to " + result + ". Saved and active."); + } else if (error instanceof IllegalArgumentException || error instanceof IllegalStateException) { + reply.accept(error.getMessage()); + } else { + report(id, error); + reply.accept("Could not save player height. Their height is unchanged; check the server log."); + } + }); + } + + private Player requireOnline(UUID id) { + Player player = plugin.getServer().getPlayer(id); + if (player == null || !player.isOnline() || player.isDead()) { + throw new IllegalStateException("Player is no longer available for a stature change."); + } + return player; + } + + private static double scale(Player player) { + AttributeInstance attribute = player.getAttribute(Attribute.SCALE); + if (attribute == null) { + throw new IllegalStateException("Player has no scale attribute."); + } + return attribute.getBaseValue(); + } + + private void applyRestoration(UUID id, double value) { + HeightStore.Sequence pending = store.sequence(id); + if (applyOnline(id, value) && pending != null && !Instant.now().isBefore(pending.expiresAt())) { + worker.execute(() -> { + try { + stature.acknowledgeRestoration(id, pending); + } catch (java.io.IOException exception) { + report(id, exception); + } + }); + } + } + + private boolean applyOnline(UUID id, double value) { + Player player = plugin.getServer().getPlayer(id); + if (plugin.isEnabled() && player != null && player.isOnline() && !player.isDead()) { + AttributeInstance attribute = player.getAttribute(Attribute.SCALE); + if (attribute != null) { + attribute.setBaseValue(value); + player.sendMessage("Your scale is now " + value + "."); + return true; + } + } + return false; + } + + private void report(UUID id, Exception error) { + plugin.getLogger().warning("Could not update stature for " + id + ": " + error.getMessage()); + } + + @Override + public void close() { + queue.close(); + // Disable is the one blocking lifecycle boundary: never permit an old writer to outlive + // this store and overwrite a replacement plugin instance. close() joins even if interrupted. + worker.close(); + completions.drain(); + } +} diff --git a/src/main/java/games/dmg/spigotheights/CompletionMailbox.java b/src/main/java/games/dmg/spigotheights/CompletionMailbox.java new file mode 100644 index 0000000..1aa72ae --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/CompletionMailbox.java @@ -0,0 +1,27 @@ +package games.dmg.spigotheights; + +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/** Retains worker outcomes even if the plugin scheduler stops during disable. */ +public final class CompletionMailbox implements Executor { + private final Consumer wakeup; + private final java.util.concurrent.ConcurrentLinkedQueue ready = new java.util.concurrent.ConcurrentLinkedQueue<>(); + + public CompletionMailbox(Consumer wakeup) { + this.wakeup = wakeup; + } + + @Override public void execute(Runnable task) { + ready.add(task); + wakeup.accept(this::drain); + } + + /** Must be called on the main thread, also after the writer joins during disable. */ + public void drain() { + Runnable task; + while ((task = ready.poll()) != null) { + task.run(); + } + } +} diff --git a/src/main/java/games/dmg/spigotheights/HeightStore.java b/src/main/java/games/dmg/spigotheights/HeightStore.java index 8e05358..147d8da 100644 --- a/src/main/java/games/dmg/spigotheights/HeightStore.java +++ b/src/main/java/games/dmg/spigotheights/HeightStore.java @@ -7,32 +7,99 @@ import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.UUID; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; public final class HeightStore { private final Path statePath; - private YamlConfiguration state; + private volatile YamlConfiguration state; + + public record Sequence(double baseline, Instant expiresAt) {} public HeightStore(File dataFolder) { statePath = dataFolder.toPath().resolve("state.yml"); state = YamlConfiguration.loadConfiguration(statePath.toFile()); } - public synchronized Double find(UUID playerId) { + public Double find(UUID playerId) { + YamlConfiguration snapshot = state; String path = path(playerId); - return state.contains(path) ? state.getDouble(path) : null; + return snapshot.contains(path) ? snapshot.getDouble(path) : null; } - public synchronized void save(UUID playerId, double scale) throws IOException { + public Sequence sequence(UUID playerId) { + YamlConfiguration snapshot = state; + String path = temporaryPath(playerId); + if (!snapshot.isDouble(path + ".baseline") && !snapshot.isInt(path + ".baseline")) { + return null; + } + double baseline = snapshot.getDouble(path + ".baseline"); + if (!Double.isFinite(baseline) || baseline < 0.0625 || baseline > 16.0) { + return null; + } + try { + return new Sequence(baseline, Instant.parse(snapshot.getString(path + ".expires-at", ""))); + } catch (DateTimeParseException exception) { + return null; + } + } + + /** A permanent write also invalidates any outstanding temporary sequence. */ + public void save(UUID playerId, double scale) throws IOException { + save(playerId, scale, null); + } + + public boolean hasReceipt(UUID playerId, UUID source) { + return state.getBoolean(receiptPath(playerId, source)); + } + + public void save(UUID playerId, double scale, Sequence sequence) throws IOException { + save(playerId, scale, sequence, null); + } + + public synchronized void save(UUID playerId, double scale, Sequence sequence, UUID source) throws IOException { + YamlConfiguration next = copy(); + next.set(path(playerId), scale); + next.set(temporaryPath(playerId), null); + if (sequence != null) { + next.set(temporaryPath(playerId) + ".baseline", sequence.baseline()); + next.set(temporaryPath(playerId) + ".expires-at", sequence.expiresAt().toString()); + } + if (source != null) { + next.set(receiptPath(playerId, source), true); + } + replace(next); + } + + public synchronized void forgetSource(UUID source) throws IOException { + var players = state.getConfigurationSection("players"); + if (players == null) { + return; + } + java.util.List paths = players.getKeys(false).stream() + .map(player -> "players." + player + ".throwables." + source) + .filter(state::contains).toList(); + if (!paths.isEmpty()) { + YamlConfiguration next = copy(); + paths.forEach(path -> next.set(path, null)); + replace(next); + } + } + + private YamlConfiguration copy() throws IOException { 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); + return next; + } + + private void replace(YamlConfiguration next) throws IOException { Files.createDirectories(statePath.getParent()); Path temporary = statePath.resolveSibling("state.yml.tmp"); Files.writeString(temporary, next.saveToString(), StandardCharsets.UTF_8); @@ -45,6 +112,14 @@ public final class HeightStore { state = next; } + private static String receiptPath(UUID playerId, UUID source) { + return "players." + playerId + ".throwables." + source; + } + + private static String temporaryPath(UUID playerId) { + return "players." + playerId + ".temporary"; + } + private static String path(UUID playerId) { return "players." + playerId + ".scale"; } diff --git a/src/main/java/games/dmg/spigotheights/HeightsCommand.java b/src/main/java/games/dmg/spigotheights/HeightsCommand.java index 76c3978..e112e3e 100644 --- a/src/main/java/games/dmg/spigotheights/HeightsCommand.java +++ b/src/main/java/games/dmg/spigotheights/HeightsCommand.java @@ -16,11 +16,22 @@ public final class HeightsCommand implements TabExecutor { private final LiveHeightSettings settings; private final Logger logger; private final PlayerHeights players; + private final HeightSetter heightSetter; + + @FunctionalInterface + public interface HeightSetter { + void set(String name, double value, java.util.function.Consumer reply) throws IOException; + } public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger) { + this(settings, players, logger, (name, value, reply) -> reply.accept(players.set(name, value))); + } + + public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter) { this.settings = settings; this.players = players; this.logger = logger; + this.heightSetter = heightSetter; } @Override @@ -67,8 +78,11 @@ public final class HeightsCommand implements TabExecutor { return true; } try { - sender.sendMessage(args.length == 2 ? players.describe(args[1]) - : players.set(args[1], Double.parseDouble(args[2]))); + if (args.length == 2) { + sender.sendMessage(players.describe(args[1])); + } else { + heightSetter.set(args[1], Double.parseDouble(args[2]), sender::sendMessage); + } } catch (NumberFormatException exception) { sender.sendMessage("Invalid scale: enter a finite number, for example 0.5 or 1.0."); } catch (IllegalArgumentException exception) { diff --git a/src/main/java/games/dmg/spigotheights/PotionRecipes.java b/src/main/java/games/dmg/spigotheights/PotionRecipes.java index d04357e..2dd60fb 100644 --- a/src/main/java/games/dmg/spigotheights/PotionRecipes.java +++ b/src/main/java/games/dmg/spigotheights/PotionRecipes.java @@ -1,10 +1,14 @@ package games.dmg.spigotheights; +import java.util.Locale; +import java.util.function.Function; +import io.papermc.paper.potion.PotionMix; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Color; import org.bukkit.Material; import org.bukkit.NamespacedKey; +import org.bukkit.Server; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.RecipeChoice; import org.bukkit.inventory.ShapedRecipe; @@ -19,12 +23,22 @@ public final class PotionRecipes { static final String[] DIMINUTION_SHAPE = {"GAG", "ASA", "GFG"}; static final String[] RESTORATION_SHAPE = {"GAG", "ASA", "GUG"}; - private final JavaPlugin plugin; + private final Server server; private final NamespacedKey potionKindKey; + private final Function items; public PotionRecipes(JavaPlugin plugin) { - this.plugin = plugin; - potionKindKey = new NamespacedKey(plugin, "stature_potion"); + this(plugin.getServer(), new NamespacedKey(plugin, "stature_potion"), ItemStack::new); + } + + PotionRecipes(Server server, NamespacedKey potionKindKey, Function items) { + this.server = server; + this.potionKindKey = potionKindKey; + this.items = items; + } + + private NamespacedKey key(String value) { + return new NamespacedKey(potionKindKey.getNamespace(), value); } public void register() { @@ -32,10 +46,46 @@ public final class PotionRecipes { registerGrowth(); registerDiminution(); registerRestoration(); + registerBrewing(server.getPotionBrewer()); + } + + public void registerBrewing(org.bukkit.potion.PotionBrewer brewer) { + for (StaturePotion kind : StaturePotion.values()) { + mix(brewer, kind, Material.POTION, Material.GUNPOWDER, Material.SPLASH_POTION); + mix(brewer, kind, Material.SPLASH_POTION, Material.DRAGON_BREATH, Material.LINGERING_POTION); + } + } + + private void mix(org.bukkit.potion.PotionBrewer brewer, StaturePotion kind, Material source, + Material ingredient, Material target) { + NamespacedKey mixKey = mixKey(kind, target); + brewer.removePotionMix(mixKey); + brewer.addPotionMix(new PotionMix(mixKey, create(kind, target), + PotionMix.createPredicateChoice(item -> item != null && item.getType() == source + && identify(item) == kind), + PotionMix.createPredicateChoice(item -> item != null && item.getType() == ingredient))); + } + + public void unregisterBrewing() { + for (StaturePotion kind : StaturePotion.values()) { + server.getPotionBrewer().removePotionMix(mixKey(kind, Material.SPLASH_POTION)); + server.getPotionBrewer().removePotionMix(mixKey(kind, Material.LINGERING_POTION)); + } + } + + private NamespacedKey mixKey(StaturePotion kind, Material target) { + return key((kind.name() + "_" + target.name()).toLowerCase(Locale.ROOT)); } public ItemStack create(StaturePotion kind) { - ItemStack item = new ItemStack(Material.POTION); + return create(kind, Material.POTION); + } + + public ItemStack create(StaturePotion kind, Material form) { + if (!isPotionForm(form)) { + throw new IllegalArgumentException("Unsupported potion form"); + } + ItemStack item = items.apply(form); PotionMeta meta = (PotionMeta) item.getItemMeta(); meta.displayName(Component.text(kind.displayName(), NamedTextColor.LIGHT_PURPLE)); meta.getPersistentDataContainer().set(potionKindKey, PersistentDataType.STRING, kind.name()); @@ -45,7 +95,8 @@ public final class PotionRecipes { } public StaturePotion identify(ItemStack item) { - if (item == null || item.getType() != Material.POTION || !(item.getItemMeta() instanceof PotionMeta meta)) { + if (item == null || !isPotionForm(item.getType()) || !(item.getItemMeta() instanceof PotionMeta meta) + || !meta.getPersistentDataContainer().has(potionKindKey, PersistentDataType.STRING)) { return null; } String value = meta.getPersistentDataContainer().get(potionKindKey, PersistentDataType.STRING); @@ -59,13 +110,17 @@ public final class PotionRecipes { } } + private static boolean isPotionForm(Material form) { + return form == Material.POTION || form == Material.SPLASH_POTION || form == Material.LINGERING_POTION; + } + public boolean isAwkwardPotion(ItemStack item) { return item != null && item.getItemMeta() instanceof PotionMeta meta && meta.getBasePotionType() == PotionType.AWKWARD; } public NamespacedKey shiftingKey() { - return new NamespacedKey(plugin, "shifting_stature"); + return key("shifting_stature"); } private void registerShifting() { @@ -74,39 +129,39 @@ public final class PotionRecipes { recipe.setIngredient('A', Material.AMETHYST_SHARD); recipe.setIngredient('C', Material.CHORUS_FRUIT); recipe.setIngredient('W', Material.POTION); - plugin.getServer().addRecipe(recipe); + server.addRecipe(recipe); } private void registerGrowth() { - ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "growth"), create(StaturePotion.GROWTH)); + ShapedRecipe recipe = new ShapedRecipe(key("growth"), create(StaturePotion.GROWTH)); recipe.shape(GROWTH_SHAPE); recipe.setIngredient('G', Material.GOLD_INGOT); recipe.setIngredient('A', Material.AMETHYST_SHARD); recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING))); recipe.setIngredient('R', Material.RABBIT_FOOT); - plugin.getServer().addRecipe(recipe); + server.addRecipe(recipe); } private void registerDiminution() { - ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "diminution"), + ShapedRecipe recipe = new ShapedRecipe(key("diminution"), create(StaturePotion.DIMINUTION)); recipe.shape(DIMINUTION_SHAPE); recipe.setIngredient('G', Material.GOLD_INGOT); recipe.setIngredient('A', Material.AMETHYST_SHARD); recipe.setIngredient('S', new RecipeChoice.ExactChoice(create(StaturePotion.SHIFTING))); recipe.setIngredient('F', Material.FERMENTED_SPIDER_EYE); - plugin.getServer().addRecipe(recipe); + server.addRecipe(recipe); } private void registerRestoration() { - ShapedRecipe recipe = new ShapedRecipe(new NamespacedKey(plugin, "restoration"), + ShapedRecipe recipe = new ShapedRecipe(key("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); + server.addRecipe(recipe); } private static Color color(StaturePotion kind) { diff --git a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java index a7455b5..4bd5e21 100644 --- a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java +++ b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java @@ -6,6 +6,8 @@ import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.plugin.java.JavaPlugin; public final class SpigotHeightsPlugin extends JavaPlugin { + private BukkitStatureService stature; + private PotionRecipes potions; @Override public void onEnable() { saveDefaultConfig(); @@ -27,19 +29,39 @@ public final class SpigotHeightsPlugin extends JavaPlugin { 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()); + stature = new BukkitStatureService(this, liveSettings, store); + HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, getLogger(), stature::set); PluginCommand command = Objects.requireNonNull(getCommand("heights"), "Missing heights command declaration"); command.setExecutor(executor); command.setTabCompleter(executor); - PotionRecipes potions = new PotionRecipes(this); + potions = new PotionRecipes(this); potions.register(); getServer().getPluginManager().registerEvents( - new StatureListener(this, liveSettings, store, potions), this); + new StatureListener(potions, stature::drink, stature::resume, + task -> getServer().getScheduler().runTask(this, task)), this); + ThrowableStatureListener throwable = new ThrowableStatureListener(potions, + new org.bukkit.NamespacedKey(this, "cloud_kind"), stature::temporary); + getServer().getPluginManager().registerEvents(throwable, this); + StatureClouds clouds = new StatureClouds(getServer(), throwable, stature::retireSource); + getServer().getPluginManager().registerEvents(clouds, this); + getServer().getScheduler().runTaskTimer(this, clouds::tick, 5L, 5L); + getServer().getScheduler().runTaskTimer(this, stature::expireOnline, 20L, 20L); + getServer().getOnlinePlayers().forEach(stature::resume); getServer().getPluginManager().registerEvents(new TinyPlayerLauncher(liveSettings), this); getLogger().info("Spigot Heights enabled."); } + @Override + public void onDisable() { + if (stature != null) { + stature.close(); + } + if (potions != null) { + potions.unregisterBrewing(); + } + } + static HeightSettings loadSettings(FileConfiguration config) { return new HeightSettings( config.getDouble("height.minimum"), diff --git a/src/main/java/games/dmg/spigotheights/StatureClouds.java b/src/main/java/games/dmg/spigotheights/StatureClouds.java new file mode 100644 index 0000000..57b673a --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/StatureClouds.java @@ -0,0 +1,69 @@ +package games.dmg.spigotheights; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.AreaEffectCloud; +import org.bukkit.entity.Entity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.LingeringPotionSplashEvent; +import org.bukkit.event.world.EntitiesLoadEvent; +import org.bukkit.event.world.EntitiesUnloadEvent; + +/** Tracks only loaded, authenticated Heights clouds; does not scan worlds every tick. */ +public final class StatureClouds implements Listener { + private final Server server; + private final ThrowableStatureListener listener; + private final java.util.function.Consumer retire; + private final Map clouds = new HashMap<>(); + + public StatureClouds(Server server, ThrowableStatureListener listener, java.util.function.Consumer retire) { + this.server = server; + this.listener = listener; + this.retire = retire; + server.getWorlds().forEach(world -> world.getEntitiesByClass(AreaEffectCloud.class).forEach(this::track)); + } + + private void track(Entity entity) { + if (entity instanceof AreaEffectCloud cloud && listener.cloudKind(cloud) != null) { + clouds.put(cloud.getUniqueId(), cloud); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCreate(LingeringPotionSplashEvent event) { + if (!event.isCancelled()) { + track(event.getAreaEffectCloud()); + } + } + + @EventHandler + public void onLoad(EntitiesLoadEvent event) { + event.getEntities().forEach(this::track); + } + + @EventHandler + public void onUnload(EntitiesUnloadEvent event) { + event.getEntities().forEach(entity -> clouds.remove(entity.getUniqueId())); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onRemove(org.bukkit.event.entity.EntityRemoveEvent event) { + if (event.getCause() != org.bukkit.event.entity.EntityRemoveEvent.Cause.UNLOAD + && listener.isStatureSource(event.getEntity())) { + clouds.remove(event.getEntity().getUniqueId()); + retire.accept(event.getEntity().getUniqueId()); + } + } + + public void tick() { + clouds.values().removeIf(cloud -> !cloud.isValid()); + // Event callbacks may load/unload entities; iterate a snapshot to avoid reentrant mutation. + for (AreaEffectCloud cloud : java.util.List.copyOf(clouds.values())) { + listener.tickCloud(cloud, server.getPluginManager()::callEvent); + } + } +} diff --git a/src/main/java/games/dmg/spigotheights/StatureListener.java b/src/main/java/games/dmg/spigotheights/StatureListener.java index 413392c..3963a38 100644 --- a/src/main/java/games/dmg/spigotheights/StatureListener.java +++ b/src/main/java/games/dmg/spigotheights/StatureListener.java @@ -1,10 +1,9 @@ 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 java.util.function.BiConsumer; +import java.util.function.Consumer; +import org.bukkit.Keyed; +import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -15,20 +14,19 @@ import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.Recipe; -import org.bukkit.Keyed; -import org.bukkit.plugin.java.JavaPlugin; public final class StatureListener implements Listener { - private final JavaPlugin plugin; - private final Supplier settings; - private final HeightStore store; private final PotionRecipes potions; + private final BiConsumer drink; + private final Consumer resume; + private final Consumer nextTick; - public StatureListener(JavaPlugin plugin, Supplier settings, HeightStore store, PotionRecipes potions) { - this.plugin = plugin; - this.settings = settings; - this.store = store; + public StatureListener(PotionRecipes potions, BiConsumer drink, + Consumer resume, Consumer nextTick) { this.potions = potions; + this.drink = drink; + this.resume = resume; + this.nextTick = nextTick; } @EventHandler @@ -45,50 +43,22 @@ public final class StatureListener implements Listener { @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onConsume(PlayerItemConsumeEvent event) { - StaturePotion kind = potions.identify(event.getItem()); - if (kind == null) { + if (event.isCancelled() || event.getItem().getType() != Material.POTION) { return; } - Player player = event.getPlayer(); - double current = currentScale(player); - double scale = kind.scaleAfterDrinking(current, settings.get(), - bound -> ThreadLocalRandom.current().nextInt(bound)); - applyAndSave(player, scale); - player.sendMessage("Your scale is now " + scale + "."); + StaturePotion kind = potions.identify(event.getItem()); + if (kind != null) { + drink.accept(event.getPlayer(), kind); + } } @EventHandler public void onJoin(PlayerJoinEvent event) { - apply(event.getPlayer(), HeightMath.safeStoredScale(store.find(event.getPlayer().getUniqueId()), settings.get())); + resume.accept(event.getPlayer()); } @EventHandler public void onRespawn(PlayerRespawnEvent event) { - plugin.getServer().getScheduler().runTask(plugin, () -> { - Player player = event.getPlayer(); - apply(player, HeightMath.safeStoredScale(store.find(player.getUniqueId()), settings.get())); - }); - } - - private void applyAndSave(Player player, double scale) { - apply(player, scale); - try { - store.save(player.getUniqueId(), scale); - } catch (IOException exception) { - plugin.getLogger().severe("Could not save scale for " + player.getUniqueId() + ": " - + exception.getMessage()); - } - } - - private static void apply(Player player, double scale) { - AttributeInstance attribute = player.getAttribute(Attribute.SCALE); - if (attribute != null) { - attribute.setBaseValue(scale); - } - } - - private static double currentScale(Player player) { - AttributeInstance attribute = player.getAttribute(Attribute.SCALE); - return attribute == null ? 1.0 : attribute.getBaseValue(); + nextTick.accept(() -> resume.accept(event.getPlayer())); } } diff --git a/src/main/java/games/dmg/spigotheights/StatureQueue.java b/src/main/java/games/dmg/spigotheights/StatureQueue.java new file mode 100644 index 0000000..4bf34ed --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/StatureQueue.java @@ -0,0 +1,77 @@ +package games.dmg.spigotheights; + +import java.util.ArrayDeque; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +/** Serializes main-thread capture, worker persistence, and main-thread completion. */ +public final class StatureQueue { + private final Executor worker; + private final Executor main; + private final ArrayDeque jobs = new ArrayDeque<>(); + private boolean running; + private boolean closed; + + private record Job(Supplier> prepare, BiConsumer complete) {} + + public StatureQueue(Executor worker, Executor main) { + this.worker = worker; + this.main = main; + } + + public void submit(Supplier> prepare, BiConsumer complete) { + if (closed) { + complete.accept(null, new IllegalStateException("Stature service is shutting down.")); + return; + } + if (jobs.size() >= 256) { + complete.accept(null, new IllegalStateException("Too many pending stature operations; try again shortly.")); + return; + } + jobs.addLast(new Job(prepare, complete)); + start(); + } + + public void close() { + closed = true; + while (!jobs.isEmpty()) { + jobs.removeFirst().complete().accept(null, new IllegalStateException("Stature service is shutting down.")); + } + } + + private void start() { + if (running || jobs.isEmpty()) { + return; + } + running = true; + Job job = jobs.removeFirst(); + try { + Callable work = job.prepare().get(); + worker.execute(() -> { + Double result = null; + Exception failure = null; + try { + result = work.call(); + } catch (Exception exception) { + failure = exception; + } + Double saved = result; + Exception error = failure; + main.execute(() -> finish(job, saved, error)); + }); + } catch (Exception exception) { + finish(job, null, exception); + } + } + + private void finish(Job job, Double result, Exception error) { + try { + job.complete().accept(result, error); + } finally { + running = false; + start(); + } + } +} diff --git a/src/main/java/games/dmg/spigotheights/TemporaryStature.java b/src/main/java/games/dmg/spigotheights/TemporaryStature.java new file mode 100644 index 0000000..ef47e1d --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/TemporaryStature.java @@ -0,0 +1,64 @@ +package games.dmg.spigotheights; + +import java.io.IOException; +import java.time.Instant; +import java.util.UUID; +import java.util.function.IntUnaryOperator; + +/** Durable stature operations, independent of the server. */ +public final class TemporaryStature { + private final HeightStore store; + + public TemporaryStature(HeightStore store) { + this.store = store; + } + + public Double applyOnce(UUID player, UUID source, double current, StaturePotion kind, HeightSettings settings, + Instant now, IntUnaryOperator random) throws IOException { + if (source != null && store.hasReceipt(player, source)) { + return null; + } + HeightStore.Sequence sequence = store.sequence(player); + if (sequence != null && !now.isBefore(sequence.expiresAt())) { + current = sequence.baseline(); + sequence = null; + } + double baseline = sequence == null ? current : sequence.baseline(); + double result = kind.scaleAfterDrinking(current, settings, random); + store.save(player, result, new HeightStore.Sequence(baseline, now.plusSeconds(300)), source); + return result; + } + + public double apply(UUID player, double current, StaturePotion kind, HeightSettings settings, + Instant now, IntUnaryOperator random) throws IOException { + return applyOnce(player, null, current, kind, settings, now, random); + } + + public double drink(UUID player, double current, StaturePotion kind, HeightSettings settings, + Instant now, IntUnaryOperator random) throws IOException { + HeightStore.Sequence sequence = store.sequence(player); + if (sequence != null && !now.isBefore(sequence.expiresAt())) { + current = sequence.baseline(); + } + double result = kind.scaleAfterDrinking(current, settings, random); + store.save(player, result); + return result; + } + + public void acknowledgeRestoration(UUID player, HeightStore.Sequence expected) throws IOException { + if (expected != null && expected.equals(store.sequence(player)) + && Double.valueOf(expected.baseline()).equals(store.find(player))) { + store.save(player, expected.baseline()); + } + } + + public double resume(UUID player, HeightSettings settings, Instant now) throws IOException { + HeightStore.Sequence sequence = store.sequence(player); + if (sequence != null && !now.isBefore(sequence.expiresAt())) { + // Keep an expired restoration pending until the server actually applies its result. + store.save(player, sequence.baseline(), sequence); + return sequence.baseline(); + } + return HeightMath.safeStoredScale(store.find(player), settings); + } +} diff --git a/src/main/java/games/dmg/spigotheights/ThrowableStatureListener.java b/src/main/java/games/dmg/spigotheights/ThrowableStatureListener.java new file mode 100644 index 0000000..33ad7fe --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/ThrowableStatureListener.java @@ -0,0 +1,151 @@ +package games.dmg.spigotheights; + +import java.util.function.Consumer; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Entity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.AreaEffectCloudApplyEvent; +import org.bukkit.event.entity.LingeringPotionSplashEvent; +import org.bukkit.event.entity.PotionSplashEvent; + +/** Bukkit delivery boundary; the effect service reports durable application success. */ +public final class ThrowableStatureListener implements Listener { + @FunctionalInterface + public interface Effects { + void apply(java.util.UUID source, Player player, StaturePotion kind, Consumer completed); + } + + private final PotionRecipes potions; + private final NamespacedKey cloudKind; + private final Effects effects; + private record Delivery(java.util.UUID source, java.util.UUID player) {} + private final java.util.Set pending = new java.util.HashSet<>(); + + public ThrowableStatureListener(PotionRecipes potions, NamespacedKey cloudKind, Effects effects) { + this.potions = potions; + this.cloudKind = cloudKind; + this.effects = effects; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSplash(PotionSplashEvent event) { + if (event.isCancelled() || event.getPotion().getItem().getType() != Material.SPLASH_POTION) { + return; + } + StaturePotion kind = potions.identify(event.getPotion().getItem()); + if (kind == null) { + return; + } + for (var entity : event.getAffectedEntities()) { + if (entity instanceof Player player && event.getIntensity(entity) > 0) { + applyOnce(event.getPotion(), player, kind); + } + } + } + + private void applyOnce(Entity source, Player player, StaturePotion kind) { + PersistentDataContainer data = source.getPersistentDataContainer(); + NamespacedKey receipt = new NamespacedKey(cloudKind.getNamespace(), "hit_" + player.getUniqueId()); + Delivery delivery = new Delivery(source.getUniqueId(), player.getUniqueId()); + if (data.has(receipt) || !player.isOnline() || player.isDead() || !pending.add(delivery)) { + return; + } + // Pending reservations are not serialized by chunk unload. The service commits the + // successful receipt atomically with stature, so reload cannot duplicate an application. + try { + effects.apply(source.getUniqueId(), player, kind, success -> { + pending.remove(delivery); + if (success) { + data.set(receipt, PersistentDataType.BYTE, (byte) 1); + } + }); + } catch (RuntimeException exception) { + pending.remove(delivery); + throw exception; + } + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onLingering(LingeringPotionSplashEvent event) { + if (event.isCancelled() || event.getEntity().getItem().getType() != Material.LINGERING_POTION) { + return; + } + StaturePotion kind = potions.identify(event.getEntity().getItem()); + if (kind != null) { + event.allowsEmptyCreation(true); + event.getAreaEffectCloud().getPersistentDataContainer().set(cloudKind, + PersistentDataType.STRING, kind.name()); + } + } + + /** Empty vanilla clouds do not discover victims. Dispatch the normal cancellable event ourselves. */ + public void tickCloud(org.bukkit.entity.AreaEffectCloud cloud, Consumer dispatch) { + if (!cloud.isValid() || cloudKind(cloud) == null || cloud.getTicksLived() < cloud.getWaitTime()) { + return; + } + double radius = cloud.getRadius(); + if (radius < 0.5) { + return; + } + var center = cloud.getLocation(); + java.util.List targets = new java.util.ArrayList<>(); + for (Entity entity : cloud.getNearbyEntities(radius, 0.5, radius)) { + if (entity instanceof Player player && player.isOnline() && !player.isDead() + && player.getGameMode() != org.bukkit.GameMode.SPECTATOR + && !hasReceipt(cloud, player) && cloud.getBoundingBox().overlaps(player.getBoundingBox())) { + var position = player.getLocation(); + double x = position.getX() - center.getX(); + double z = position.getZ() - center.getZ(); + if (x * x + z * z <= radius * radius) { + targets.add(player); + } + } + } + if (!targets.isEmpty()) { + dispatch.accept(new AreaEffectCloudApplyEvent(cloud, targets)); + } + } + + private boolean hasReceipt(Entity source, Player player) { + return pending.contains(new Delivery(source.getUniqueId(), player.getUniqueId())) + || source.getPersistentDataContainer().has(new NamespacedKey(cloudKind.getNamespace(), + "hit_" + player.getUniqueId())); + } + + boolean isStatureSource(Entity source) { + return source instanceof org.bukkit.entity.AreaEffectCloud cloud && cloudKind(cloud) != null + || source instanceof org.bukkit.entity.ThrownPotion thrown && potions.identify(thrown.getItem()) != null; + } + + StaturePotion cloudKind(org.bukkit.entity.AreaEffectCloud cloud) { + PersistentDataContainer data = cloud.getPersistentDataContainer(); + if (!data.has(cloudKind, PersistentDataType.STRING)) { + return null; + } + try { + return StaturePotion.valueOf(data.get(cloudKind, PersistentDataType.STRING)); + } catch (IllegalArgumentException exception) { + return null; + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onCloud(AreaEffectCloudApplyEvent event) { + if (event.isCancelled()) { + return; + } + StaturePotion kind = cloudKind(event.getEntity()); + if (kind != null) { + for (var entity : event.getAffectedEntities()) { + if (entity instanceof Player player) { + applyOnce(event.getEntity(), player, kind); + } + } + } + } +} diff --git a/src/test/java/games/dmg/spigotheights/BukkitDoubles.java b/src/test/java/games/dmg/spigotheights/BukkitDoubles.java new file mode 100644 index 0000000..76cc2f0 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/BukkitDoubles.java @@ -0,0 +1,65 @@ +package games.dmg.spigotheights; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +/** Small boundary doubles: no server installation or mocked domain rules. */ +final class BukkitDoubles { + private BukkitDoubles() {} + + static T proxy(Class type, InvocationHandler handler) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, handler)); + } + + static PersistentDataContainer container() { + Map values = new HashMap<>(); + Map> types = new HashMap<>(); + return proxy(PersistentDataContainer.class, (proxy, method, args) -> { + return switch (method.getName()) { + case "set" -> { + values.put((NamespacedKey) args[0], args[2]); + types.put((NamespacedKey) args[0], (PersistentDataType) args[1]); + yield null; + } + case "get" -> types.get(args[0]) == args[1] ? values.get(args[0]) : null; + case "has" -> values.containsKey(args[0]) && (args.length == 1 || types.get(args[0]) == args[1]); + case "remove" -> { types.remove(args[0]); values.remove(args[0]); yield null; } + case "getKeys" -> Set.copyOf(values.keySet()); + case "isEmpty" -> values.isEmpty(); + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + } + + static final class Item extends ItemStack { + private final Material material; + private final PersistentDataContainer data = container(); + private Object displayName; + private final PotionMeta meta = proxy(PotionMeta.class, (proxy, method, args) -> { + return switch (method.getName()) { + case "getPersistentDataContainer" -> data; + case "displayName" -> { + if (args != null && args.length == 1) { displayName = args[0]; yield null; } + yield displayName; + } + case "setColor" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }; + }); + + Item(Material material) { this.material = material; } + @Override public Material getType() { return material; } + @Override public PotionMeta getItemMeta() { return meta; } + @Override public boolean setItemMeta(org.bukkit.inventory.meta.ItemMeta value) { return value == meta; } + @Override public Item clone() { return this; } + } +} diff --git a/src/test/java/games/dmg/spigotheights/CompletionMailboxTest.java b/src/test/java/games/dmg/spigotheights/CompletionMailboxTest.java new file mode 100644 index 0000000..7a4b200 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/CompletionMailboxTest.java @@ -0,0 +1,22 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class CompletionMailboxTest { + @Test + void shutdownCanDrainExactlyOnceEvenWhenScheduledWakeupWasDropped() { + List scheduled = new ArrayList<>(); + List results = new ArrayList<>(); + CompletionMailbox mailbox = new CompletionMailbox(scheduled::add); + mailbox.execute(() -> results.add("failed save released reservation")); + assertEquals(List.of(), results); + mailbox.drain(); + assertEquals(List.of("failed save released reservation"), results); + scheduled.forEach(Runnable::run); + mailbox.drain(); + assertEquals(1, results.size()); + } +} diff --git a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java index 02fa346..282fc1f 100644 --- a/src/test/java/games/dmg/spigotheights/HeightStoreTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightStoreTest.java @@ -40,6 +40,23 @@ class HeightStoreTest { assertEquals(1.0, HeightMath.safeStoredScale(reloaded.find(playerId), settings)); } + @Test + void sourceRemovalRetiresOnlyItsReceiptsWithoutChangingStature() throws Exception { + UUID player = UUID.randomUUID(); + UUID source = UUID.randomUUID(); + UUID other = UUID.randomUUID(); + HeightStore store = new HeightStore(temporaryDirectory.toFile()); + HeightStore.Sequence sequence = new HeightStore.Sequence(1.0, java.time.Instant.EPOCH.plusSeconds(300)); + store.save(player, 0.8, sequence, source); + store.save(player, 0.6, sequence, other); + store.forgetSource(source); + HeightStore reloaded = new HeightStore(temporaryDirectory.toFile()); + assertEquals(false, reloaded.hasReceipt(player, source)); + assertEquals(true, reloaded.hasReceipt(player, other)); + assertEquals(0.6, reloaded.find(player)); + assertEquals(sequence, reloaded.sequence(player)); + } + @Test void storesByUuidAndPreservesUnknownYamlFields() throws Exception { UUID playerId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java index d3c0aaa..0443d62 100644 --- a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java @@ -37,6 +37,25 @@ class HeightsCommandTest { command = new HeightsCommand(live, players, Logger.getAnonymousLogger()); } + @Test + void runtimeHeightSetterCanPersistAsynchronouslyWithoutBypassingPermissions() { + List> replies = new ArrayList<>(); + HeightsCommand async = new HeightsCommand(live, players, Logger.getAnonymousLogger(), (name, value, reply) -> { + assertEquals("Steve", name); + assertEquals(0.5, value); + replies.add(reply); + }); + async.onCommand(sender(false), null, "heights", new String[] {"player", "Steve", "0.5"}); + assertEquals(0, replies.size()); + messages.clear(); + async.onCommand(sender(true), null, "heights", new String[] {"player", "Steve", "0.5"}); + assertEquals(1, replies.size()); + assertEquals(List.of(), messages); + assertEquals(0.7, playerScale); + replies.getFirst().accept("Saved and active."); + assertEquals(List.of("Saved and active."), messages); + } + @Test void viewsAndSetsOnlinePlayerWithNameAndSizeCompletion() { CommandSender sender = sender(true); diff --git a/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java b/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java index f6646b8..1c3d0c5 100644 --- a/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java +++ b/src/test/java/games/dmg/spigotheights/PotionRecipesTest.java @@ -4,9 +4,72 @@ 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 static org.junit.jupiter.api.Assertions.assertNull; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; import org.junit.jupiter.api.Test; class PotionRecipesTest { + private final NamespacedKey key = new NamespacedKey("spigotheights", "stature_potion"); + private final PotionRecipes potions = new PotionRecipes(null, key, BukkitDoubles.Item::new); + + @Test + void authenticatesAllFormsByTypedMetadataNotDisplayName() { + for (StaturePotion kind : StaturePotion.values()) { + for (Material form : new Material[] {Material.POTION, Material.SPLASH_POTION, Material.LINGERING_POTION}) { + BukkitDoubles.Item item = new BukkitDoubles.Item(form); + item.getItemMeta().displayName(net.kyori.adventure.text.Component.text(kind.displayName())); + assertNull(potions.identify(item)); + item.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.INTEGER, 1); + assertNull(potions.identify(item)); + item.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, "UNKNOWN"); + assertNull(potions.identify(item)); + item.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name()); + assertEquals(kind, potions.identify(item)); + } + BukkitDoubles.Item fake = new BukkitDoubles.Item(Material.STONE); + fake.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name()); + assertNull(potions.identify(fake)); + } + assertNull(potions.identify(null)); + } + + @Test + void registersEightScopedBrewingConversionsWithAuthenticatedResults() { + java.util.List mixes = new java.util.ArrayList<>(); + java.util.List removed = new java.util.ArrayList<>(); + org.bukkit.potion.PotionBrewer brewer = BukkitDoubles.proxy(org.bukkit.potion.PotionBrewer.class, + (proxy, method, args) -> { + switch (method.getName()) { + case "addPotionMix" -> mixes.add((io.papermc.paper.potion.PotionMix) args[0]); + case "removePotionMix" -> removed.add((NamespacedKey) args[0]); + default -> throw new UnsupportedOperationException(method.getName()); + } + return null; + }); + potions.registerBrewing(brewer); + assertEquals(8, mixes.size()); + assertEquals(8, mixes.stream().map(io.papermc.paper.potion.PotionMix::getKey).distinct().count()); + assertEquals(mixes.stream().map(io.papermc.paper.potion.PotionMix::getKey).toList(), removed); + for (var mix : mixes) { + Material target = mix.getResult().getType(); + boolean splash = target == Material.SPLASH_POTION; + assertEquals(splash ? Material.SPLASH_POTION : Material.LINGERING_POTION, target); + StaturePotion kind = potions.identify(mix.getResult()); + org.junit.jupiter.api.Assertions.assertNotNull(kind); + Material source = splash ? Material.POTION : Material.SPLASH_POTION; + BukkitDoubles.Item input = new BukkitDoubles.Item(source); + org.junit.jupiter.api.Assertions.assertFalse(mix.getInput().test(input)); + input.getItemMeta().getPersistentDataContainer().set(key, PersistentDataType.STRING, kind.name()); + org.junit.jupiter.api.Assertions.assertTrue(mix.getInput().test(input)); + org.junit.jupiter.api.Assertions.assertFalse(mix.getInput().test(mix.getResult())); + org.junit.jupiter.api.Assertions.assertTrue(mix.getIngredient().test(new BukkitDoubles.Item( + splash ? Material.GUNPOWDER : Material.DRAGON_BREATH))); + org.junit.jupiter.api.Assertions.assertFalse(mix.getIngredient().test(new BukkitDoubles.Item(Material.SUGAR))); + } + } + @Test void recipesUseApprovedShapes() { assertArrayEquals(new String[] {"ACA", "AWA", "ACA"}, PotionRecipes.SHIFTING_SHAPE); diff --git a/src/test/java/games/dmg/spigotheights/StatureCloudsTest.java b/src/test/java/games/dmg/spigotheights/StatureCloudsTest.java new file mode 100644 index 0000000..721796a --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/StatureCloudsTest.java @@ -0,0 +1,36 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Server; +import org.bukkit.entity.ThrownPotion; +import org.bukkit.event.entity.EntityRemoveEvent; +import org.junit.jupiter.api.Test; + +class StatureCloudsTest { + @Test + void unloadRetainsDurableReceiptsButPermanentSourceRemovalRetiresThem() { + PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), + BukkitDoubles.Item::new); + ThrowableStatureListener listener = new ThrowableStatureListener(potions, + new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> {}); + Server server = BukkitDoubles.proxy(Server.class, (proxy, method, args) -> { + if (method.getName().equals("getWorlds")) { return List.of(); } + throw new UnsupportedOperationException(method.getName()); + }); + List retired = new ArrayList<>(); + StatureClouds clouds = new StatureClouds(server, listener, retired::add); + ThrownPotion source = ThrowableStatureListenerTest.entity(ThrownPotion.class, + potions.create(StaturePotion.GROWTH, Material.SPLASH_POTION)); + clouds.onRemove(new EntityRemoveEvent(source, EntityRemoveEvent.Cause.UNLOAD)); + clouds.onRemove(new EntityRemoveEvent(ThrowableStatureListenerTest.entity(ThrownPotion.class, + new BukkitDoubles.Item(Material.SPLASH_POTION)), EntityRemoveEvent.Cause.HIT)); + assertEquals(List.of(), retired); + clouds.onRemove(new EntityRemoveEvent(source, EntityRemoveEvent.Cause.HIT)); + assertEquals(List.of(source.getUniqueId()), retired); + } +} diff --git a/src/test/java/games/dmg/spigotheights/StatureListenerTest.java b/src/test/java/games/dmg/spigotheights/StatureListenerTest.java new file mode 100644 index 0000000..f688f0b --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/StatureListenerTest.java @@ -0,0 +1,32 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.junit.jupiter.api.Test; + +class StatureListenerTest { + @Test + void onlyUncancelledAuthenticatedDrinkableItemsReachPermanentService() { + PotionRecipes potions = new PotionRecipes(null, new NamespacedKey("spigotheights", "stature_potion"), + BukkitDoubles.Item::new); + List drinks = new ArrayList<>(); + StatureListener listener = new StatureListener(potions, (player, kind) -> drinks.add(kind), player -> {}, Runnable::run); + Player player = ThrowableStatureListenerTest.entity(Player.class, null); + var cancelled = new PlayerItemConsumeEvent(player, potions.create(StaturePotion.GROWTH), EquipmentSlot.HAND); + cancelled.setCancelled(true); + listener.onConsume(cancelled); + listener.onConsume(new PlayerItemConsumeEvent(player, + potions.create(StaturePotion.GROWTH, Material.SPLASH_POTION), EquipmentSlot.HAND)); + listener.onConsume(new PlayerItemConsumeEvent(player, new BukkitDoubles.Item(Material.POTION), EquipmentSlot.HAND)); + assertEquals(List.of(), drinks); + cancelled.setCancelled(false); + listener.onConsume(cancelled); + assertEquals(List.of(StaturePotion.GROWTH), drinks); + } +} diff --git a/src/test/java/games/dmg/spigotheights/StatureQueueTest.java b/src/test/java/games/dmg/spigotheights/StatureQueueTest.java new file mode 100644 index 0000000..bec276d --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/StatureQueueTest.java @@ -0,0 +1,68 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class StatureQueueTest { + @Test + void capturesNextOperationOnlyAfterDurableCompletionAndNeverWritesOnMain() { + ArrayDeque worker = new ArrayDeque<>(); + ArrayDeque main = new ArrayDeque<>(); + List events = new ArrayList<>(); + double[] scale = {1.0}; + StatureQueue queue = new StatureQueue(worker::add, main::add); + for (int i = 0; i < 2; i++) { + queue.submit(() -> { + events.add("capture"); + double next = scale[0] - 0.2; + return () -> { events.add("save"); return next; }; + }, (result, error) -> { assertNull(error); events.add("apply"); scale[0] = result; }); + } + assertEquals(List.of("capture"), events); + assertEquals(1.0, scale[0]); + worker.removeFirst().run(); + assertEquals(List.of("capture", "save"), events); + assertEquals(1.0, scale[0]); + main.removeFirst().run(); + assertEquals(List.of("capture", "save", "apply", "capture"), events); + worker.removeFirst().run(); main.removeFirst().run(); + assertEquals(0.6, scale[0], 0.00001); + } + + @Test + void shutdownRejectsPendingAndNewOperationsButAllowsInflightSaveToFinish() { + ArrayDeque worker = new ArrayDeque<>(); + ArrayDeque main = new ArrayDeque<>(); + List failures = new ArrayList<>(); + StatureQueue queue = new StatureQueue(worker::add, main::add); + queue.submit(() -> () -> 0.8, (result, error) -> assertNull(error)); + queue.submit(() -> () -> 0.6, (result, error) -> failures.add(error)); + queue.close(); + queue.submit(() -> () -> 0.4, (result, error) -> failures.add(error)); + assertEquals(2, failures.size()); + failures.forEach(org.junit.jupiter.api.Assertions::assertNotNull); + worker.removeFirst().run(); main.removeFirst().run(); + assertEquals(0, worker.size()); + } + + @Test + void failedPersistenceReportsFailureAndDoesNotStrandNextOperation() { + ArrayDeque worker = new ArrayDeque<>(); + ArrayDeque main = new ArrayDeque<>(); + List applied = new ArrayList<>(); + StatureQueue queue = new StatureQueue(worker::add, main::add); + queue.submit(() -> () -> { throw new java.io.IOException("disk failure"); }, + (result, error) -> { assertNull(result); assertNotNull(error); }); + queue.submit(() -> () -> 1.4, (result, error) -> { assertNull(error); applied.add(result); }); + assertEquals(1, worker.size()); + worker.removeFirst().run(); main.removeFirst().run(); + worker.removeFirst().run(); main.removeFirst().run(); + assertEquals(List.of(1.4), applied); + } +} diff --git a/src/test/java/games/dmg/spigotheights/TemporaryStatureTest.java b/src/test/java/games/dmg/spigotheights/TemporaryStatureTest.java new file mode 100644 index 0000000..0f5981b --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/TemporaryStatureTest.java @@ -0,0 +1,127 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TemporaryStatureTest { + @TempDir Path directory; + private final UUID player = UUID.randomUUID(); + private final HeightSettings settings = new HeightSettings(0.2, 3.0, 0.2, 0.5, 1.5, 20); + private final Instant start = Instant.parse("2026-01-01T00:00:00Z"); + + @Test + void applicationAfterExpiryStartsFromRestoredBaselineNotStaleTemporaryScale() throws Exception { + TemporaryStature stature = new TemporaryStature(new HeightStore(directory.toFile())); + stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0); + assertEquals(1.2, stature.apply(player, 0.8, StaturePotion.GROWTH, settings, + start.plusSeconds(300), bound -> 0)); + assertEquals(1.0, stature.resume(player, settings, start.plusSeconds(600))); + } + + @Test + void durableCloudReceiptPreventsDuplicateAfterUnloadReloadAndPermanentOverride() throws Exception { + UUID source = UUID.randomUUID(); + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + assertEquals(0.8, stature.applyOnce(player, source, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0)); + store.save(player, 1.4); + HeightStore reloaded = new HeightStore(directory.toFile()); + assertNull(new TemporaryStature(reloaded).applyOnce(player, source, 1.4, StaturePotion.DIMINUTION, + settings, start.plusSeconds(10), bound -> 0)); + assertEquals(1.4, reloaded.find(player)); + assertNull(reloaded.sequence(player)); + } + + @Test + void unobservedExpirySurvivesDisconnectBetweenSaveAndLiveApplication() throws Exception { + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + stature.apply(player, 2.0, StaturePotion.DIMINUTION, settings, start, bound -> 0); + HeightSettings smaller = new HeightSettings(0.2, 0.8, 0.2, 0.5, 1.5, 20); + assertEquals(2.0, stature.resume(player, smaller, start.plusSeconds(300))); + // Player disconnected before observing the result: login must still restore exactly. + assertEquals(2.0, new TemporaryStature(new HeightStore(directory.toFile())) + .resume(player, smaller, start.plusSeconds(301))); + } + + @Test + void reloadCountsOfflineTimeAndRestoresExactBaselineOutsideChangedLimits() throws Exception { + TemporaryStature stature = new TemporaryStature(new HeightStore(directory.toFile())); + stature.apply(player, 2.0, StaturePotion.DIMINUTION, settings, start, bound -> 0); + HeightStore reloaded = new HeightStore(directory.toFile()); + TemporaryStature restarted = new TemporaryStature(reloaded); + HeightSettings smaller = new HeightSettings(0.2, 0.8, 0.2, 0.5, 1.5, 20); + assertEquals(2.0, restarted.resume(player, smaller, start.plusSeconds(301))); + restarted.acknowledgeRestoration(player, reloaded.sequence(player)); + assertNull(reloaded.sequence(player)); + // Only processing the expiry bypasses new limits; later ordinary joins use them. + assertEquals(0.8, restarted.resume(player, smaller, start.plusSeconds(302))); + } + + @Test + void permanentSaveEndsSequenceAndFailedSavePreservesIt() throws Exception { + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0); + Files.createDirectory(directory.resolve("state.yml.tmp")); + assertThrows(IOException.class, () -> store.save(player, 1.4)); + assertEquals(0.8, store.find(player)); + assertEquals(1.0, store.sequence(player).baseline()); + Files.delete(directory.resolve("state.yml.tmp")); + store.save(player, 1.4); + assertNull(new HeightStore(directory.toFile()).sequence(player)); + assertEquals(1.4, stature.resume(player, settings, start.plusSeconds(600))); + } + + @Test + void permanentDrinkUsesCurrentSizeAndEndsSequenceIncludingAtExpiryBoundary() throws Exception { + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0); + assertEquals(0.6, stature.drink(player, 0.8, StaturePotion.DIMINUTION, settings, + start.plusSeconds(1), bound -> 0)); + assertNull(store.sequence(player)); + assertEquals(0.6, stature.resume(player, settings, start.plusSeconds(600))); + stature.apply(player, 0.6, StaturePotion.GROWTH, settings, start, bound -> 0); + assertEquals(0.4, stature.drink(player, 0.8, StaturePotion.DIMINUTION, settings, + start.plusSeconds(300), bound -> 0)); + assertNull(store.sequence(player)); + } + + @Test + void allKindsRespectBoundsRestorationAndTimerRefreshEvenAtBounds() throws Exception { + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + assertEquals(0.2, stature.apply(player, 0.2, StaturePotion.DIMINUTION, settings, start, bound -> 0)); + assertEquals(3.0, stature.apply(player, 0.2, StaturePotion.SHIFTING, settings, + start.plusSeconds(10), bound -> bound - 1)); + assertEquals(3.0, stature.apply(player, 3.0, StaturePotion.GROWTH, settings, + start.plusSeconds(20), bound -> 0)); + assertEquals(start.plusSeconds(320), store.sequence(player).expiresAt()); + assertEquals(1.0, stature.apply(player, 3.0, StaturePotion.RESTORATION, settings, + start.plusSeconds(30), bound -> 0)); + assertEquals(0.2, stature.resume(player, settings, start.plusSeconds(330))); + } + + @Test + void chainedEffectsRestoreFirstBaselineFiveMinutesAfterLastApplication() throws Exception { + HeightStore store = new HeightStore(directory.toFile()); + TemporaryStature stature = new TemporaryStature(store); + assertEquals(0.8, stature.apply(player, 1.0, StaturePotion.DIMINUTION, settings, start, bound -> 0)); + assertEquals(0.6, stature.apply(player, 0.8, StaturePotion.DIMINUTION, settings, + start.plusSeconds(240), bound -> 0)); + assertEquals(0.6, stature.resume(player, settings, start.plusSeconds(539))); + assertEquals(1.0, stature.resume(player, settings, start.plusSeconds(540))); + assertEquals(1.0, store.find(player)); + } +} diff --git a/src/test/java/games/dmg/spigotheights/ThrowableStatureListenerTest.java b/src/test/java/games/dmg/spigotheights/ThrowableStatureListenerTest.java new file mode 100644 index 0000000..c30b0da --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/ThrowableStatureListenerTest.java @@ -0,0 +1,161 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.AreaEffectCloud; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.entity.ThrownPotion; +import org.bukkit.event.entity.AreaEffectCloudApplyEvent; +import org.bukkit.event.entity.LingeringPotionSplashEvent; +import org.bukkit.event.entity.PotionSplashEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataContainer; +import org.junit.jupiter.api.Test; + +class ThrowableStatureListenerTest { + private final PotionRecipes potions = new PotionRecipes(null, + new NamespacedKey("spigotheights", "stature_potion"), BukkitDoubles.Item::new); + private final List applied = new ArrayList<>(); + private final List> pending = new ArrayList<>(); + private boolean asynchronous; + private final ThrowableStatureListener listener = new ThrowableStatureListener(potions, + new NamespacedKey("spigotheights", "cloud_kind"), (source, player, kind, done) -> { + applied.add(kind); + if (asynchronous) { pending.add(done); } else { done.accept(true); } + }); + + @Test + void splashUsesAuthenticatedItemPositiveIntensityAndOncePerPlayer() { + Player player = entity(Player.class, null); + Player missed = entity(Player.class, null); + LivingEntity mob = entity(LivingEntity.class, null); + ThrownPotion thrown = entity(ThrownPotion.class, potions.create(StaturePotion.DIMINUTION, Material.SPLASH_POTION)); + Map recipients = new HashMap<>(); + recipients.put(player, 0.1); recipients.put(missed, 0.0); recipients.put(mob, 1.0); + PotionSplashEvent event = new PotionSplashEvent(thrown, null, null, null, recipients); + event.setCancelled(true); + listener.onSplash(event); + assertEquals(List.of(), applied); + event.setCancelled(false); + listener.onSplash(event); + listener.onSplash(event); + assertEquals(List.of(StaturePotion.DIMINUTION), applied); + ThrownPotion ordinary = entity(ThrownPotion.class, new BukkitDoubles.Item(Material.SPLASH_POTION)); + listener.onSplash(new PotionSplashEvent(ordinary, null, null, null, recipients)); + assertEquals(1, applied.size()); + } + + @Test + void cloudRequiresUncancelledAuthenticatedCreationAndAppliesOnlyOncePerPlayer() { + Player player = entity(Player.class, null); + AreaEffectCloud cloud = entity(AreaEffectCloud.class, null); + var application = new AreaEffectCloudApplyEvent(cloud, new ArrayList<>(List.of(player))); + listener.onCloud(application); + assertEquals(0, applied.size()); + var creation = new LingeringPotionSplashEvent(entity(ThrownPotion.class, + potions.create(StaturePotion.GROWTH, Material.LINGERING_POTION)), null, null, null, cloud); + creation.setCancelled(true); + listener.onLingering(creation); + assertFalse(creation.allowsEmptyCreation()); + listener.onCloud(application); + assertEquals(0, applied.size()); + creation.setCancelled(false); + listener.onLingering(creation); + assertTrue(creation.allowsEmptyCreation()); + application.setCancelled(true); + listener.onCloud(application); + assertEquals(0, applied.size()); + application.setCancelled(false); + asynchronous = true; + listener.onCloud(application); + listener.onCloud(application); + assertEquals(List.of(StaturePotion.GROWTH), applied); + assertFalse(cloud.getPersistentDataContainer().has(new NamespacedKey("spigotheights", + "hit_" + player.getUniqueId())), "Pending reservations must not be serialized on unload"); + pending.removeFirst().accept(false); + listener.onCloud(application); + assertEquals(2, applied.size()); + pending.removeFirst().accept(true); + listener.onCloud(application); + assertEquals(2, applied.size()); + // Receipts are cloud-owned metadata, not player-object or listener-instance identity. + new ThrowableStatureListener(potions, new NamespacedKey("spigotheights", "cloud_kind"), + (source, recipient, kind, done) -> { throw new AssertionError("duplicate after listener restart"); }) + .onCloud(application); + } + + @Test + void effectlessCloudTickDispatchesCancellableEventAndHonorsWaitAndRadius() { + Player inside = locatedPlayer(1.0, 0.0); + Player outsideCircle = locatedPlayer(2.0, 2.0); + PersistentDataContainer data = BukkitDoubles.container(); + int[] age = {9}; + UUID cloudId = UUID.randomUUID(); + AreaEffectCloud cloud = BukkitDoubles.proxy(AreaEffectCloud.class, (proxy, method, args) -> switch (method.getName()) { + case "getPersistentDataContainer" -> data; + case "isValid" -> true; + case "getUniqueId" -> cloudId; + case "getTicksLived" -> age[0]; + case "getWaitTime" -> 10; + case "getRadius" -> 2.0f; + case "getLocation" -> new org.bukkit.Location(null, 0, 0, 0); + case "getBoundingBox" -> new org.bukkit.util.BoundingBox(-2, 0, -2, 2, 0.5, 2); + case "getNearbyEntities" -> List.of(inside, outsideCircle); + default -> throw new UnsupportedOperationException(method.getName()); + }); + listener.onLingering(new LingeringPotionSplashEvent(entity(ThrownPotion.class, + potions.create(StaturePotion.RESTORATION, Material.LINGERING_POTION)), null, null, null, cloud)); + List dispatched = new ArrayList<>(); + Consumer cancel = event -> { + dispatched.add(event); event.setCancelled(true); listener.onCloud(event); + }; + listener.tickCloud(cloud, cancel); + assertEquals(0, dispatched.size()); + age[0] = 10; + listener.tickCloud(cloud, cancel); + assertEquals(1, dispatched.size()); + assertEquals(List.of(inside), dispatched.getFirst().getAffectedEntities()); + assertEquals(0, applied.size()); + listener.tickCloud(cloud, listener::onCloud); + listener.tickCloud(cloud, listener::onCloud); + assertEquals(List.of(StaturePotion.RESTORATION), applied); + } + + private static Player locatedPlayer(double x, double z) { + Player delegate = entity(Player.class, null); + return BukkitDoubles.proxy(Player.class, (proxy, method, args) -> switch (method.getName()) { + case "getLocation" -> new org.bukkit.Location(null, x, 0, z); + case "getBoundingBox" -> new org.bukkit.util.BoundingBox(x - 0.2, 0, z - 0.2, x + 0.2, 1.8, z + 0.2); + case "getGameMode" -> org.bukkit.GameMode.SURVIVAL; + case "equals" -> proxy == args[0]; + default -> method.invoke(delegate, args); + }); + } + + static T entity(Class type, ItemStack item) { + UUID id = UUID.randomUUID(); + PersistentDataContainer data = BukkitDoubles.container(); + return BukkitDoubles.proxy(type, (proxy, method, args) -> switch (method.getName()) { + case "getUniqueId" -> id; + case "getPersistentDataContainer" -> data; + case "getItem" -> item; + case "isOnline", "isValid" -> true; + case "isDead" -> false; + case "hashCode" -> id.hashCode(); + case "equals" -> proxy == args[0]; + case "toString" -> id.toString(); + default -> throw new UnsupportedOperationException(method.getName()); + }); + } +}