feat(heights): add durable temporary throwable stature
This commit is contained in:
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user