feat(heights): add durable temporary throwable stature
Release / release (push) Successful in 2m36s
CI / build (push) Successful in 1m1s

This commit is contained in:
dmg
2026-09-10 22:41:20 -04:00
parent 82415ed501
commit 47d5060dac
22 changed files with 1434 additions and 75 deletions
@@ -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<HeightSettings> 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<UUID> expiring = new HashSet<>();
public BukkitStatureService(JavaPlugin plugin, Supplier<HeightSettings> 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<Boolean> 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<Boolean> 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<Double> 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<String> 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();
}
}
@@ -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<Runnable> wakeup;
private final java.util.concurrent.ConcurrentLinkedQueue<Runnable> ready = new java.util.concurrent.ConcurrentLinkedQueue<>();
public CompletionMailbox(Consumer<Runnable> 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();
}
}
}
@@ -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<String> 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";
}
@@ -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<String> 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) {
@@ -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<Material, ItemStack> 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<Material, ItemStack> 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) {
@@ -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"),
@@ -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<UUID> retire;
private final Map<UUID, AreaEffectCloud> clouds = new HashMap<>();
public StatureClouds(Server server, ThrowableStatureListener listener, java.util.function.Consumer<UUID> 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);
}
}
}
@@ -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<HeightSettings> settings;
private final HeightStore store;
private final PotionRecipes potions;
private final BiConsumer<Player, StaturePotion> drink;
private final Consumer<Player> resume;
private final Consumer<Runnable> nextTick;
public StatureListener(JavaPlugin plugin, Supplier<HeightSettings> settings, HeightStore store, PotionRecipes potions) {
this.plugin = plugin;
this.settings = settings;
this.store = store;
public StatureListener(PotionRecipes potions, BiConsumer<Player, StaturePotion> drink,
Consumer<Player> resume, Consumer<Runnable> 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()));
}
}
@@ -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<Job> jobs = new ArrayDeque<>();
private boolean running;
private boolean closed;
private record Job(Supplier<Callable<Double>> prepare, BiConsumer<Double, Exception> complete) {}
public StatureQueue(Executor worker, Executor main) {
this.worker = worker;
this.main = main;
}
public void submit(Supplier<Callable<Double>> prepare, BiConsumer<Double, Exception> 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<Double> 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();
}
}
}
@@ -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);
}
}
@@ -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<Boolean> 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<Delivery> 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<AreaEffectCloudApplyEvent> 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<org.bukkit.entity.LivingEntity> 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);
}
}
}
}
}
@@ -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> T proxy(Class<T> type, InvocationHandler handler) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type}, handler));
}
static PersistentDataContainer container() {
Map<NamespacedKey, Object> values = new HashMap<>();
Map<NamespacedKey, PersistentDataType<?, ?>> 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; }
}
}
@@ -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<Runnable> scheduled = new ArrayList<>();
List<String> 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());
}
}
@@ -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();
@@ -37,6 +37,25 @@ class HeightsCommandTest {
command = new HeightsCommand(live, players, Logger.getAnonymousLogger());
}
@Test
void runtimeHeightSetterCanPersistAsynchronouslyWithoutBypassingPermissions() {
List<java.util.function.Consumer<String>> 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);
@@ -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<io.papermc.paper.potion.PotionMix> mixes = new java.util.ArrayList<>();
java.util.List<NamespacedKey> 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);
@@ -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<UUID> 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);
}
}
@@ -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<StaturePotion> 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);
}
}
@@ -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<Runnable> worker = new ArrayDeque<>();
ArrayDeque<Runnable> main = new ArrayDeque<>();
List<String> 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<Runnable> worker = new ArrayDeque<>();
ArrayDeque<Runnable> main = new ArrayDeque<>();
List<Exception> 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<Runnable> worker = new ArrayDeque<>();
ArrayDeque<Runnable> main = new ArrayDeque<>();
List<Double> 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);
}
}
@@ -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));
}
}
@@ -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<StaturePotion> applied = new ArrayList<>();
private final List<Consumer<Boolean>> 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<LivingEntity, Double> 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<AreaEffectCloudApplyEvent> dispatched = new ArrayList<>();
Consumer<AreaEffectCloudApplyEvent> 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> T entity(Class<T> 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());
});
}
}