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);
}
}
}
}
}