feat(ignis): add earned Heart of Shadows gameplay
Release / release (push) Successful in 2m35s
CI / build (push) Successful in 1m35s

This commit is contained in:
dmg committed 2026-09-11 00:18:48 -04:00
1 parent 17b61d7b43
commit 79c9c7fea7
37 files changed
+1554 -158

No files matched your search

+13 -3
View File
@@ -1,6 +1,6 @@
# Spigot Ignis
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, and The Dagger are implemented in source; live-server/client verification is pending.** Heart gameplay is not implemented yet.
Server-wide earned shadow progression for Social Minecraft. **Echo of the Abyss, advancement-name scrambling, The Mask, The Dagger, and The Heart of Shadows are implemented in source; live-server/client verification is pending.**
## Build
@@ -77,7 +77,17 @@ Safety uses the player's current bounding box, a continuous sweep against block
## Commands
All players may use `/ignis umbra` to privately see the three item tracks. Mask and Dagger counters are active; Heart natural progression and its item remain unimplemented, though administrator-seeded values can be inspected. No passive progress UI is added.
All players may use `/ignis umbra` to privately see the three active item tracks. No passive progress UI is added.
### The Heart of Shadows
Earn the personal unlock through six uninterrupted online hours at total light zero (configurable). Uncancelled positive final damage, leaving darkness, disconnecting, or restarting interrupts unfinished progress. Completed unlocks persist until administrative reset. Light observation never loads chunks; unavailable lighting cannot qualify.
Craft a Creaking Heart surrounded orthogonally by four Ominous Bottle V items, with four Lingering Potions of Instant Health II in the corners. The registered recipe uses exact vanilla potion/bottle metadata, and preparation/click checks independently validate the matrix. Automated Crafters are denied; issued Hearts, including revoked copies, cannot be placed as blocks.
Carrying an authenticated Heart requires both the holder's unlock and a valid issuer receipt/generation. It grants Regeneration II at total light zero, or at night in natural skylit worlds. Multiple copies do not stack. Existing stronger regeneration is left alone; weaker regeneration overlaid by Ignis is restored with its remaining server-tick duration on withdrawal. External clears/replacements and cancellation take precedence. Cleanup and native potion-event ordering still require live verification.
Heart counters checkpoint periodically off-thread. Startup durably clears unfinished streaks before enabling gameplay; shutdown drains interruption writes before closing persistence. Administrator progress edits flush and rebase the active interval rather than replaying older elapsed progress.
Administration requires **`ignis.admin`** (default: operators), checked independently during execution and autocomplete:
@@ -87,7 +97,7 @@ Administration requires **`ignis.admin`** (default: operators), checked independ
/ignisadmin limits set <mask-ms|pearls|darkness-ms> <positive-value>
/ignisadmin progress <set|add> <online-name|known-UUID> <mask|dagger|heart> <nonnegative-value>
/ignisadmin complete <online-name|known-UUID> void
/ignisadmin give <online-name|known-UUID> <echo|mask|dagger>
/ignisadmin give <online-name|known-UUID> <echo|mask|dagger|heart>
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all>
/ignisadmin reset <online-name|known-UUID> <void|mask|dagger|heart|all> confirm
```
@@ -1,121 +1,28 @@
package games.dmg.spigotignis;
import java.util.function.Function;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.plugin.java.JavaPlugin;
public final class DaggerCrafting implements Listener, Runnable {
public final class DaggerCrafting extends ReceiptCrafting {
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "dagger");
private static final TradableRewards REWARDS = new TradableRewards(Track.DAGGER);
private final JavaPlugin plugin;
private final StateService states;
private final DaggerItems items;
private final EchoGameplay dispatch;
private final java.util.function.Function<org.bukkit.inventory.ItemStack, org.bukkit.inventory.ShapedRecipe> recipeFactory;
public DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch) {
this(plugin, states, items, dispatch, result -> new org.bukkit.inventory.ShapedRecipe(RECIPE, result));
this(plugin, states, items, dispatch, result -> new ShapedRecipe(RECIPE, result));
}
DaggerCrafting(JavaPlugin plugin, StateService states, DaggerItems items, EchoGameplay dispatch,
java.util.function.Function<org.bukkit.inventory.ItemStack, org.bukkit.inventory.ShapedRecipe> recipeFactory) {
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
this.recipeFactory = recipeFactory;
Function<ItemStack, ShapedRecipe> recipeFactory) {
super(plugin, states, items, dispatch, Track.DAGGER, RECIPE, recipeFactory);
}
private boolean registered;
private final java.util.Set<java.util.UUID> preparing = new java.util.HashSet<>();
private final java.util.Set<ItemToken> delivering = new java.util.HashSet<>();
private final java.util.Set<ItemToken> offered = new java.util.HashSet<>();
@Override public void run() {
if (!registered) {
var recipe = recipeFactory.apply(items.prototype());
configureRecipe(recipe);
registered = plugin.getServer().addRecipe(recipe);
if (!registered) return;
}
for (var player : plugin.getServer().getOnlinePlayers()) {
var state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
if (!state.track(Track.DAGGER).unlocked()) {
if (player.hasDiscoveredRecipe(RECIPE)) player.undiscoverRecipe(RECIPE);
continue;
}
ensureReceipt(player);
if (!player.hasDiscoveredRecipe(RECIPE)) player.discoverRecipe(RECIPE);
for (var entry : state.pendingRewards().entrySet()) {
if (entry.getValue() != Track.DAGGER) continue;
var token = new ItemToken(player.getUniqueId(), entry.getKey(), state.track(Track.DAGGER).generation());
if (delivering.contains(token)) continue;
if (!offered.contains(token) && !items.offer(player, token)) continue;
offered.add(token);
delivering.add(token);
states.update(current -> REWARDS.acknowledge(current, token))
.whenComplete((ignored, error) -> dispatch.onServer(() -> {
delivering.remove(token);
if (error == null) offered.remove(token);
}));
}
}
}
private void ensureReceipt(org.bukkit.entity.Player player) {
var owner = player.getUniqueId();
var snapshot = states.snapshot();
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(Track.DAGGER);
if (!state.unlocked() || REWARDS.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
var receipt = java.util.UUID.randomUUID();
long generation = state.generation();
states.update(current -> {
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(Track.DAGGER);
return now.unlocked() && now.generation() == generation ? REWARDS.issue(current, owner, receipt, false) : current;
}).whenComplete((ignored, error) -> dispatch.onServer(() -> {
preparing.remove(owner);
var online = plugin.getServer().getPlayer(owner);
if (error != null || online == null) return;
var view = online.getOpenInventory();
if (view.getTopInventory() instanceof org.bukkit.inventory.CraftingInventory inventory && matches(inventory.getRecipe())) {
inventory.setResult(items.prototype());
plugin.getServer().getPluginManager().callEvent(new org.bukkit.event.inventory.PrepareItemCraftEvent(inventory, view, false));
online.updateInventory();
}
}));
}
public void close() { if (registered) plugin.getServer().removeRecipe(RECIPE); }
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(CraftItemEvent event) {
if (!matches(event.getRecipe())) return;
var identity = items.identify(event.getInventory().getResult());
if (!(event.getWhoClicked() instanceof org.bukkit.entity.Player player) || identity.isEmpty()
|| !identity.orElseThrow().issuer().equals(player.getUniqueId())
|| !REWARDS.valid(states.snapshot(), player.getUniqueId(), identity.orElseThrow())) {
event.setCancelled(true);
event.getInventory().setResult(null);
}
}
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
public void onCrafter(CrafterCraftEvent event) {
if (matches(event.getRecipe())) event.setCancelled(true);
}
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST)
public void onPrepare(org.bukkit.event.inventory.PrepareItemCraftEvent event) {
if (!matches(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
if (!(event.getView().getPlayer() instanceof org.bukkit.entity.Player player)) {
event.getInventory().setResult(null);
return;
}
var token = REWARDS.craftingToken(states.snapshot(), player.getUniqueId());
event.getInventory().setResult(token.map(items::create).orElse(null));
if (token.isEmpty()) ensureReceipt(player);
}
static void configureRecipe(org.bukkit.inventory.ShapedRecipe recipe) {
@Override protected void configure(ShapedRecipe recipe) { configureRecipe(recipe); }
static void configureRecipe(ShapedRecipe recipe) {
recipe.shape("HCH", "NKN", "HEH");
recipe.setIngredient('H', org.bukkit.Material.HEART_OF_THE_SEA);
recipe.setIngredient('C', org.bukkit.Material.HEAVY_CORE);
recipe.setIngredient('N', org.bukkit.Material.NETHER_STAR);
recipe.setIngredient('K', org.bukkit.Material.OMINOUS_TRIAL_KEY);
recipe.setIngredient('E', org.bukkit.Material.DRAGON_EGG);
}
private static boolean matches(org.bukkit.inventory.Recipe recipe) {
return recipe instanceof org.bukkit.Keyed keyed && keyed.getKey().equals(RECIPE);
recipe.setIngredient('H', Material.HEART_OF_THE_SEA);
recipe.setIngredient('C', Material.HEAVY_CORE);
recipe.setIngredient('N', Material.NETHER_STAR);
recipe.setIngredient('K', Material.OMINOUS_TRIAL_KEY);
recipe.setIngredient('E', Material.DRAGON_EGG);
}
}
@@ -1,41 +1,12 @@
package games.dmg.spigotignis;
import java.util.Optional;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
public final class DaggerItems {
private final java.util.function.Supplier<ItemStack> factory;
public DaggerItems() { this(() -> new ItemStack(org.bukkit.Material.OMINOUS_TRIAL_KEY)); }
DaggerItems(java.util.function.Supplier<ItemStack> factory) { this.factory = factory; }
public ItemStack prototype() {
var item = factory.get();
var meta = item.getItemMeta();
meta.displayName(net.kyori.adventure.text.Component.text("The Dagger", net.kyori.adventure.text.format.NamedTextColor.DARK_PURPLE));
item.setItemMeta(meta);
return item;
}
public ItemStack create(ItemToken token) {
var item = prototype();
var meta = item.getItemMeta();
meta.getPersistentDataContainer().set(IDENTITY, org.bukkit.persistence.PersistentDataType.STRING,
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
item.setItemMeta(meta);
return item;
}
private static final org.bukkit.NamespacedKey IDENTITY = new org.bukkit.NamespacedKey("spigotignis", "dagger_identity");
public boolean offer(org.bukkit.entity.Player player, ItemToken token) {
for (var item : player.getInventory().getContents()) {
if (identify(item).filter(token::equals).isPresent()) return true;
}
if (identify(player.getItemOnCursor()).filter(token::equals).isPresent()) return true;
if (player.getInventory().firstEmpty() < 0) return false;
return player.getInventory().addItem(create(token)).isEmpty();
}
public Optional<ItemToken> identify(ItemStack item) {
if (item == null || item.getType() != org.bukkit.Material.OMINOUS_TRIAL_KEY || !item.hasItemMeta()) return Optional.empty();
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, org.bukkit.persistence.PersistentDataType.STRING))
.map(token -> new ItemToken(token.owner(), token.receipt(), token.generation()));
public final class DaggerItems extends ReceiptItems {
public DaggerItems() { this(() -> new ItemStack(Material.OMINOUS_TRIAL_KEY)); }
DaggerItems(Supplier<ItemStack> factory) {
super(Material.OMINOUS_TRIAL_KEY, "dagger_identity", "The Dagger", factory);
}
}
@@ -0,0 +1,100 @@
package games.dmg.spigotignis;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.LongSupplier;
/** Session identity prevents interrupted intervals from being replayed by queued writes. */
public final class DarknessProgress {
private final StateService states;
private final LongSupplier clock;
private final Map<UUID, Session> sessions = new HashMap<>();
private static final class Session {
final long generation;
final DarknessStreak streak;
DarknessStreak.Progress desired;
boolean active;
Session(long generation, long seed, LongSupplier clock) {
this.generation = generation;
streak = new DarknessStreak(clock, seed);
desired = new DarknessStreak.Progress(seed, false);
}
}
public DarknessProgress(StateService states, LongSupplier clock) { this.states = states; this.clock = clock; }
private Session session(UUID player) {
var track = states.snapshot().players().getOrDefault(player, PlayerState.empty()).track(Track.HEART);
return sessions.compute(player, (ignored, old) -> old == null || old.generation != track.generation()
? new Session(track.generation(), track.progress(), clock) : old);
}
public synchronized void observe(UUID player, boolean dark) {
if (!dark) { interrupt(player); return; }
Session session = session(player);
session.active = true;
session.desired = session.streak.sample(true, states.snapshot().limits().darknessMillis());
}
public synchronized void interrupt(UUID player) {
Session session = session(player);
if (session.desired.complete()) return;
if (session.active || session.desired.millis() != 0) {
sessions.put(player, new Session(session.generation, 0, clock));
}
}
public synchronized CompletableFuture<IgnisState> edit(UUID player, long value, boolean add) {
Session before = session(player);
return checkpoint(player).thenCompose(ignored -> states.update(state ->
ProgressAdministration.progress(state, player, Track.HEART, value, add))).thenApply(state -> {
synchronized (this) { sessions.remove(player, before); }
return state;
});
}
private record Capture(Session session, DarknessStreak.Progress desired) { }
private CompletableFuture<Void> batch;
public synchronized CompletableFuture<Void> checkpointAll() {
if (batch == null || batch.isDone()) batch = saveBatch();
return batch;
}
public synchronized CompletableFuture<Void> stopAll() {
new java.util.ArrayList<>(sessions.keySet()).forEach(this::interrupt);
// Shutdown must persist the new interruptions even if an older batch is in flight.
batch = saveBatch();
return batch;
}
private CompletableFuture<Void> saveBatch() {
var captured = new HashMap<UUID, Capture>();
sessions.forEach((id, session) -> captured.put(id, new Capture(session, session.desired)));
if (captured.isEmpty()) return CompletableFuture.completedFuture(null);
return states.update(state -> {
var players = new HashMap<>(state.players());
captured.forEach((id, capture) -> {
var current = players.getOrDefault(id, PlayerState.empty());
var heart = current.track(Track.HEART);
synchronized (this) {
if (sessions.get(id) != capture.session() || heart.generation() != capture.session().generation || heart.unlocked()) return;
}
var tracks = new HashMap<>(current.tracks());
tracks.put(Track.HEART, new TrackState(capture.desired().millis(), capture.desired().complete(), heart.activated(), heart.generation()));
players.put(id, new PlayerState(tracks, current.pendingRewards(), current.issuedRewards()));
});
return new IgnisState(state.limits(), players).evaluate(state.limits());
}).thenApply(ignored -> null);
}
public synchronized CompletableFuture<IgnisState> checkpoint(UUID player) {
Session session = session(player);
var desired = session.desired;
return states.update(state -> {
var current = state.players().getOrDefault(player, PlayerState.empty());
var heart = current.track(Track.HEART);
synchronized (this) {
if (sessions.get(player) != session || heart.generation() != session.generation || heart.unlocked()) return state;
}
var tracks = new HashMap<>(current.tracks());
tracks.put(Track.HEART, new TrackState(desired.millis(), desired.complete(), heart.activated(), heart.generation()));
var players = new HashMap<>(state.players());
players.put(player, new PlayerState(tracks, current.pendingRewards(), current.issuedRewards()));
return new IgnisState(state.limits(), players).evaluate(state.limits());
});
}
}
@@ -0,0 +1,38 @@
package games.dmg.spigotignis;
import java.util.function.LongSupplier;
/** Online-session clock only; persistence and environmental observations are separate adapters. */
public final class DarknessStreak {
public record Progress(long millis, boolean complete) { }
private final LongSupplier clock;
private boolean active;
private long started;
private Progress progress = new Progress(0, false);
public DarknessStreak(LongSupplier clock) { this.clock = clock; }
private long baseMillis;
public DarknessStreak(LongSupplier clock, long seedMillis) {
this(clock);
if (seedMillis < 0) throw new IllegalArgumentException("Negative streak seed.");
baseMillis = seedMillis;
progress = new Progress(seedMillis, false);
}
public Progress sample(boolean dark, long requiredMillis) {
if (requiredMillis <= 0) throw new IllegalArgumentException("Darkness limit must be positive.");
if (progress.complete()) return progress;
if (!dark) return interrupt();
long now = clock.getAsLong();
if (!active) { started = now; active = true; }
long elapsed = Math.max(progress.millis(), Math.addExact(baseMillis, Math.max(0, now - started) / 1_000_000));
progress = new Progress(elapsed, elapsed >= requiredMillis);
return progress;
}
public Progress interrupt() {
active = false;
if (!progress.complete()) {
baseMillis = 0;
progress = new Progress(0, false);
}
return progress;
}
}
@@ -0,0 +1,7 @@
package games.dmg.spigotignis;
/** Night only supplies the alternative activation condition in normal day/night worlds. */
public final class HeartConditions {
private HeartConditions() { }
public static boolean active(boolean darkness, boolean normalWorld, boolean night) { return darkness || (normalWorld && night); }
}
@@ -0,0 +1,31 @@
package games.dmg.spigotignis;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.plugin.java.JavaPlugin;
public final class HeartCrafting extends ReceiptCrafting {
public static final NamespacedKey RECIPE = new NamespacedKey("spigotignis", "heart");
public HeartCrafting(JavaPlugin plugin, StateService states, HeartItems items, EchoGameplay dispatch) {
super(plugin, states, items, dispatch, Track.HEART, RECIPE, result -> new ShapedRecipe(RECIPE, result));
}
@Override protected boolean accepts(org.bukkit.inventory.ItemStack[] matrix) { return HeartIngredients.matrix(matrix); }
@Override protected void configure(ShapedRecipe recipe) {
var bottle = new org.bukkit.inventory.ItemStack(org.bukkit.Material.OMINOUS_BOTTLE);
var omen = (org.bukkit.inventory.meta.OminousBottleMeta) bottle.getItemMeta();
omen.setAmplifier(4);
bottle.setItemMeta(omen);
var potion = new org.bukkit.inventory.ItemStack(org.bukkit.Material.LINGERING_POTION);
var meta = (org.bukkit.inventory.meta.PotionMeta) potion.getItemMeta();
meta.setBasePotionType(org.bukkit.potion.PotionType.STRONG_HEALING);
potion.setItemMeta(meta);
configureRecipe(recipe, new RecipeChoice.ExactChoice(bottle), new RecipeChoice.ExactChoice(potion));
}
static void configureRecipe(ShapedRecipe recipe, RecipeChoice bottle, RecipeChoice healing) {
recipe.shape("PBP", "BCB", "PBP");
recipe.setIngredient('B', bottle);
recipe.setIngredient('P', healing);
recipe.setIngredient('C', org.bukkit.Material.CREAKING_HEART);
}
}
@@ -0,0 +1,68 @@
package games.dmg.spigotignis;
import java.util.Arrays;
import java.util.function.Function;
import java.util.function.Predicate;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
public final class HeartEffects implements Listener, Runnable {
private final JavaPlugin plugin;
private final HeartRegeneration regeneration;
private final Predicate<Player> eligible;
private final Function<Player, HeartRegeneration.Channel> channels;
public HeartEffects(JavaPlugin plugin, StateService states, HeartItems items) {
this(plugin, new HeartRegeneration(() -> Integer.toUnsignedLong(plugin.getServer().getCurrentTick())), player -> {
var world = player.getWorld();
var tokens = Arrays.stream(player.getInventory().getContents()).flatMap(item -> items.identify(item).stream()).toList();
return !player.isDead() && HeartEligibility.eligible(states.snapshot(), player.getUniqueId(), tokens,
HeartEnvironment.dark(player.getLocation()),
world.isNatural() && world.hasSkyLight(), !world.isDayTime());
}, HeartEffects::channel);
}
HeartEffects(JavaPlugin plugin, HeartRegeneration regeneration, Predicate<Player> eligible,
Function<Player, HeartRegeneration.Channel> channels) {
this.plugin = plugin; this.regeneration = regeneration; this.eligible = eligible; this.channels = channels;
}
@Override public void run() {
for (var player : plugin.getServer().getOnlinePlayers()) {
regeneration.tick(player.getUniqueId(), eligible.test(player), channels.apply(player));
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEffect(EntityPotionEffectEvent event) {
if (event.getEntity() instanceof Player player && event.getModifiedType().equals(PotionEffectType.REGENERATION)) {
regeneration.changed(player.getUniqueId(), event.getAction() != EntityPotionEffectEvent.Action.CHANGED || event.isOverride(),
event.getNewEffect() != null);
}
}
@EventHandler public void onQuit(PlayerQuitEvent event) { withdraw(event.getPlayer()); }
private void withdraw(Player player) {
regeneration.tick(player.getUniqueId(), false, channels.apply(player));
regeneration.forget(player.getUniqueId());
}
public void close() { plugin.getServer().getOnlinePlayers().forEach(this::withdraw); }
private static HeartRegeneration.Channel channel(Player player) {
return new HeartRegeneration.Channel() {
@Override public HeartRegeneration.Effect current() {
var current = player.getPotionEffect(PotionEffectType.REGENERATION);
return current == null ? null : new HeartRegeneration.Effect(current.getAmplifier(), current.getDuration(),
current.isAmbient(), current.hasParticles(), current.hasIcon());
}
@Override public void apply(HeartRegeneration.Effect effect) {
player.addPotionEffect(new PotionEffect(PotionEffectType.REGENERATION, effect.ticks(), effect.amplifier(),
effect.ambient(), effect.particles(), effect.icon()));
}
@Override public void remove() { player.removePotionEffect(PotionEffectType.REGENERATION); }
};
}
}
@@ -0,0 +1,14 @@
package games.dmg.spigotignis;
import java.util.Collection;
import java.util.UUID;
public final class HeartEligibility {
private static final TradableRewards REWARDS = new TradableRewards(Track.HEART);
private HeartEligibility() { }
public static boolean eligible(IgnisState state, UUID holder, Collection<ItemToken> items,
boolean dark, boolean normalWorld, boolean night) {
return HeartConditions.active(dark, normalWorld, night)
&& items.stream().anyMatch(token -> REWARDS.valid(state, holder, token));
}
}
@@ -0,0 +1,14 @@
package games.dmg.spigotignis;
import org.bukkit.Location;
/** Read-only light observation; never loads a chunk to establish eligibility. */
public final class HeartEnvironment {
private HeartEnvironment() { }
public static boolean dark(Location location) {
if (location == null || location.getWorld() == null) return false;
var world = location.getWorld();
return world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)
&& world.getBlockAt(location.getBlockX(), location.getBlockY(), location.getBlockZ()).getLightLevel() == 0;
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotignis;
import org.bukkit.inventory.ItemStack;
public final class HeartIngredients {
private HeartIngredients() { }
public static boolean matrix(ItemStack[] items) {
if (items == null || items.length != 9 || items[4] == null || items[4].getType() != org.bukkit.Material.CREAKING_HEART) return false;
for (int slot : new int[]{0, 2, 6, 8}) if (!healing(items[slot])) return false;
for (int slot : new int[]{1, 3, 5, 7}) if (!bottle(items[slot])) return false;
return true;
}
public static boolean bottle(ItemStack item) {
return item != null && item.getType() == org.bukkit.Material.OMINOUS_BOTTLE
&& item.getItemMeta() instanceof org.bukkit.inventory.meta.OminousBottleMeta meta
&& meta.hasAmplifier() && meta.getAmplifier() == 4;
}
public static boolean healing(ItemStack item) {
return item != null && item.getType() == org.bukkit.Material.LINGERING_POTION
&& item.getItemMeta() instanceof org.bukkit.inventory.meta.PotionMeta meta
&& meta.getBasePotionType() == org.bukkit.potion.PotionType.STRONG_HEALING && !meta.hasCustomEffects();
}
}
@@ -0,0 +1,12 @@
package games.dmg.spigotignis;
import java.util.function.Supplier;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
public final class HeartItems extends ReceiptItems {
public HeartItems() { this(() -> new ItemStack(Material.CREAKING_HEART)); }
HeartItems(Supplier<ItemStack> factory) {
super(Material.CREAKING_HEART, "heart_identity", "The Heart of Shadows", factory);
}
}
@@ -0,0 +1,14 @@
package games.dmg.spigotignis;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockPlaceEvent;
/** Issued Hearts are inventory rewards, not placeable vanilla blocks. */
public final class HeartPlacement implements Listener {
private final HeartItems items;
public HeartPlacement(HeartItems items) { this.items = items; }
@org.bukkit.event.EventHandler(priority = org.bukkit.event.EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
if (items.identify(event.getItemInHand()).isPresent()) event.setCancelled(true);
}
}
@@ -0,0 +1,56 @@
package games.dmg.spigotignis;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;
/** Main-thread observations; persistence is delegated to the serialized state worker. */
public final class HeartProgressListener implements Listener, Runnable {
private final JavaPlugin plugin;
private final DarknessProgress progress;
private final Predicate<Location> dark;
private final Consumer<Throwable> errors;
public HeartProgressListener(JavaPlugin plugin, DarknessProgress progress, Consumer<Throwable> errors) {
this(plugin, progress, HeartEnvironment::dark, errors);
}
HeartProgressListener(JavaPlugin plugin, DarknessProgress progress, Predicate<Location> dark, Consumer<Throwable> errors) {
this.plugin = plugin; this.progress = progress; this.dark = dark; this.errors = errors;
}
@Override public void run() {
for (var player : plugin.getServer().getOnlinePlayers()) {
progress.observe(player.getUniqueId(), !player.isDead() && dark.test(player.getLocation()));
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (!event.isCancelled() && event.getFinalDamage() > 0 && event.getEntity() instanceof Player player) {
interruptAndSave(player.getUniqueId());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (!event.isCancelled() && event.getTo() != null && !dark.test(event.getTo())) {
progress.interrupt(event.getPlayer().getUniqueId());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) { onMove(event); }
@EventHandler public void onQuit(PlayerQuitEvent event) { interruptAndSave(event.getPlayer().getUniqueId()); }
private void interruptAndSave(UUID player) {
progress.interrupt(player);
progress.checkpoint(player).whenComplete((ignored, error) -> { if (error != null) errors.accept(error); });
}
public void checkpoint() {
progress.checkpointAll().whenComplete((ignored, error) -> { if (error != null) errors.accept(error); });
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotignis;
/** Durable restart normalization for unfinished online-only darkness streaks. */
public final class HeartProgressState {
private HeartProgressState() { }
public static java.util.concurrent.CompletableFuture<StateService> prepare(StateService service) {
return service.update(HeartProgressState::restart).handle((state, error) -> {
if (error != null) {
service.close();
throw new java.util.concurrent.CompletionException(error);
}
return service;
});
}
public static IgnisState restart(IgnisState state) {
var players = new java.util.HashMap<>(state.players());
players.replaceAll((id, player) -> {
var heart = player.track(Track.HEART);
if (heart.unlocked() || heart.progress() == 0) return player;
var tracks = new java.util.HashMap<>(player.tracks());
tracks.put(Track.HEART, new TrackState(0, false, heart.activated(), heart.generation()));
return new PlayerState(tracks, player.pendingRewards(), player.issuedRewards());
});
return new IgnisState(state.limits(), players);
}
}
@@ -0,0 +1,65 @@
package games.dmg.spigotignis;
import java.util.UUID;
import java.util.function.LongSupplier;
/** Regeneration II overlay with restoration of an existing weaker effect. */
public final class HeartRegeneration {
public record Effect(int amplifier, int ticks, boolean ambient, boolean particles, boolean icon) { }
public interface Channel {
Effect current();
void apply(Effect effect);
void remove();
}
private record Backup(Effect effect, long tick) { }
private final LongSupplier ticks;
private final EffectOwnership ownership = new EffectOwnership();
private final java.util.Set<UUID> changing = new java.util.HashSet<>();
private final java.util.Map<UUID, Backup> backups = new java.util.HashMap<>();
public HeartRegeneration(LongSupplier ticks) { this.ticks = ticks; }
public void changed(UUID player, boolean actual, boolean remains) {
if (actual && !changing.contains(player)) backups.remove(player);
ownership.changed(player, actual, remains);
}
public void tick(UUID player, boolean eligible, Channel channel) {
var current = channel.current();
if (!eligible) {
if (!ownership.owns(player)) return;
mutate(player, channel::remove);
var after = channel.current();
if (after != null) {
if (!after.equals(current)) forget(player); // Preserve an external replacement.
return; // Removal was cancelled or another effect is now present.
}
ownership.forget(player); // Disabled plugins may not receive removal events.
Backup backup = backups.remove(player);
if (backup != null) {
long elapsed = (ticks.getAsLong() - backup.tick()) & 0xffff_ffffL;
int remaining = backup.effect().ticks() == -1 ? -1 : (int) Math.max(0L, backup.effect().ticks() - elapsed);
if (remaining != 0) {
Effect old = backup.effect();
channel.apply(new Effect(old.amplifier(), remaining, old.ambient(), old.particles(), old.icon()));
}
}
return;
}
if (current == null || current.amplifier() < 1
|| ownership.owns(player) && current.ticks() >= 0 && current.ticks() <= 20) {
if (!ownership.owns(player) && current != null) backups.put(player, new Backup(current, ticks.getAsLong()));
mutate(player, () -> channel.apply(new Effect(1, 40, true, false, true)));
if (!ownership.owns(player)) backups.remove(player);
}
}
public void forget(UUID player) {
ownership.forget(player);
changing.remove(player);
backups.remove(player);
}
private void mutate(UUID player, Runnable change) {
changing.add(player);
ownership.applying(player, true);
try { change.run(); }
finally { changing.remove(player); ownership.applying(player, false); }
}
}
@@ -20,6 +20,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
private final EchoGameplay gameplay;
private final InvisibilityProgress maskTracker;
private final PearlProgress pearlTracker;
private final DarknessProgress heartTracker;
private final Map<CommandSender, Confirmation> confirmations = new HashMap<>();
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay) {
@@ -32,11 +33,17 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
InvisibilityProgress maskTracker, PearlProgress pearlTracker) {
this(plugin, states, gameplay, maskTracker, pearlTracker, null);
}
public IgnisCommands(JavaPlugin plugin, StateService states, EchoGameplay gameplay,
InvisibilityProgress maskTracker, PearlProgress pearlTracker, DarknessProgress heartTracker) {
this.plugin = plugin;
this.states = states;
this.gameplay = gameplay;
this.maskTracker = maskTracker;
this.pearlTracker = pearlTracker;
this.heartTracker = heartTracker;
}
@Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
@@ -86,8 +93,9 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
? maskTracker.checkpoint(id) : track == Track.DAGGER && pearlTracker != null
? pearlTracker.checkpoint(id) : CompletableFuture.completedFuture(states.snapshot());
mutation(sender, "progress " + id + " " + track + " " + args[1] + " " + value,
checkpoint.thenCompose(ignored -> states.update(state -> ProgressAdministration.progress(state, id, track, value, add))));
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && Set.of("mask", "dagger").contains(args[2].toLowerCase(Locale.ROOT))) {
track == Track.HEART && heartTracker != null ? heartTracker.edit(id, value, add)
: checkpoint.thenCompose(ignored -> states.update(state -> ProgressAdministration.progress(state, id, track, value, add))));
} else if (args.length == 3 && args[0].equalsIgnoreCase("give") && Set.of("mask", "dagger", "heart").contains(args[2].toLowerCase(Locale.ROOT))) {
UUID id = resolve(args[1]), receipt = UUID.randomUUID();
var rewards = new TradableRewards(Track.valueOf(args[2].toUpperCase(Locale.ROOT)));
mutation(sender, "grant " + args[2] + " " + id, states.update(state -> rewards.issue(state, id, receipt, true)));
@@ -115,7 +123,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
} else throw new IllegalArgumentException("Use reset <player|UUID> <track|all> [confirm].");
} else sender.sendMessage("Ignis admin: inspect <player|UUID>; limits [set <mask-ms|pearls|darkness-ms> <value>]; "
+ "progress <set|add> <player|UUID> <mask|dagger|heart> <value>; complete <player|UUID> void; "
+ "give <player|UUID> <echo|mask|dagger>; reset <player|UUID> <track|all> [confirm].");
+ "give <player|UUID> <echo|mask|dagger|heart>; reset <player|UUID> <track|all> [confirm].");
} catch (IllegalArgumentException | ArithmeticException exception) {
sender.sendMessage("Invalid Ignis command: " + exception.getMessage());
}
@@ -174,7 +182,7 @@ public final class IgnisCommands implements CommandExecutor, TabCompleter {
if (args.length == 2) options = List.of("set", "add");
else if (args.length == 4) options = List.of("mask", "dagger", "heart");
else if (args.length == 5) options = List.of("0", "1");
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask", "dagger");
} else if (args.length == 3 && sub.equals("give")) options = List.of("echo", "mask", "dagger", "heart");
else if (args.length == 3 && sub.equals("complete")) options = List.of("void");
else if (sub.equals("reset")) {
if (args.length == 3) options = List.of("void", "mask", "dagger", "heart", "all");
@@ -0,0 +1,125 @@
package games.dmg.spigotignis;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.CrafterCraftEvent;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.plugin.java.JavaPlugin;
/** Shared preissued-receipt crafting and delivery; vanilla owns ingredient consumption. */
public abstract class ReceiptCrafting implements Listener, Runnable {
private final JavaPlugin plugin;
private final StateService states;
private final ReceiptItems items;
private final EchoGameplay dispatch;
private final Track track;
private final NamespacedKey key;
private final TradableRewards rewards;
private final Function<ItemStack, ShapedRecipe> recipeFactory;
private boolean registered;
private final Set<UUID> preparing = new HashSet<>();
private final Set<ItemToken> delivering = new HashSet<>();
private final Set<ItemToken> offered = new HashSet<>();
protected ReceiptCrafting(JavaPlugin plugin, StateService states, ReceiptItems items, EchoGameplay dispatch,
Track track, NamespacedKey key, Function<ItemStack, ShapedRecipe> recipeFactory) {
this.plugin = plugin; this.states = states; this.items = items; this.dispatch = dispatch;
this.track = track; this.key = key; this.recipeFactory = recipeFactory;
rewards = new TradableRewards(track);
}
protected abstract void configure(ShapedRecipe recipe);
protected boolean accepts(ItemStack[] matrix) { return true; }
@Override public void run() {
if (!registered) {
var recipe = recipeFactory.apply(items.prototype());
configure(recipe);
registered = plugin.getServer().addRecipe(recipe);
if (!registered) return;
}
for (Player player : plugin.getServer().getOnlinePlayers()) {
var state = states.snapshot().players().getOrDefault(player.getUniqueId(), PlayerState.empty());
if (!state.track(track).unlocked()) {
if (player.hasDiscoveredRecipe(key)) player.undiscoverRecipe(key);
continue;
}
ensureReceipt(player);
if (!player.hasDiscoveredRecipe(key)) player.discoverRecipe(key);
for (var entry : state.pendingRewards().entrySet()) {
if (entry.getValue() != track) continue;
var token = new ItemToken(player.getUniqueId(), entry.getKey(), state.track(track).generation());
if (delivering.contains(token)) continue;
if (!offered.contains(token) && !items.offer(player, token)) continue;
offered.add(token);
delivering.add(token);
states.update(current -> rewards.acknowledge(current, token)).whenComplete((ignored, error) -> dispatch.onServer(() -> {
delivering.remove(token);
if (error == null) offered.remove(token);
}));
}
}
}
private void ensureReceipt(Player player) {
var owner = player.getUniqueId();
var snapshot = states.snapshot();
var state = snapshot.players().getOrDefault(owner, PlayerState.empty()).track(track);
if (!state.unlocked() || rewards.craftingToken(snapshot, owner).isPresent() || !preparing.add(owner)) return;
var receipt = UUID.randomUUID();
long generation = state.generation();
states.update(current -> {
var now = current.players().getOrDefault(owner, PlayerState.empty()).track(track);
return now.unlocked() && now.generation() == generation ? rewards.issue(current, owner, receipt, false) : current;
}).whenComplete((ignored, error) -> dispatch.onServer(() -> {
preparing.remove(owner);
var online = plugin.getServer().getPlayer(owner);
if (error != null || online == null) return;
var view = online.getOpenInventory();
if (view.getTopInventory() instanceof CraftingInventory inventory && matches(inventory.getRecipe())) {
inventory.setResult(items.prototype());
plugin.getServer().getPluginManager().callEvent(new PrepareItemCraftEvent(inventory, view, false));
online.updateInventory();
}
}));
}
public void close() { if (registered) plugin.getServer().removeRecipe(key); }
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(CraftItemEvent event) {
if (!matches(event.getRecipe())) return;
var identity = items.identify(event.getInventory().getResult());
if (!accepts(event.getInventory().getMatrix()) || !(event.getWhoClicked() instanceof Player player) || identity.isEmpty()
|| !identity.orElseThrow().issuer().equals(player.getUniqueId())
|| !rewards.valid(states.snapshot(), player.getUniqueId(), identity.orElseThrow())) {
event.setCancelled(true);
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCrafter(CrafterCraftEvent event) { if (matches(event.getRecipe())) event.setCancelled(true); }
@EventHandler(priority = EventPriority.HIGHEST)
public void onPrepare(PrepareItemCraftEvent event) {
if (!matches(event.getInventory().getRecipe()) || event.getInventory().getResult() == null) return;
if (!accepts(event.getInventory().getMatrix()) || !(event.getView().getPlayer() instanceof Player player)) {
event.getInventory().setResult(null);
return;
}
var token = rewards.craftingToken(states.snapshot(), player.getUniqueId());
event.getInventory().setResult(token.map(items::create).orElse(null));
if (token.isEmpty()) ensureReceipt(player);
}
private boolean matches(Recipe recipe) { return recipe instanceof Keyed keyed && keyed.getKey().equals(key); }
}
@@ -0,0 +1,53 @@
package games.dmg.spigotignis;
import java.util.Optional;
import java.util.function.Supplier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
/** Plain-item receipt encoding and delivery; appearance and track identity are fixed by adapters. */
public class ReceiptItems {
private final Material material;
private final NamespacedKey identity;
private final String title;
private final Supplier<ItemStack> factory;
protected ReceiptItems(Material material, String key, String title, Supplier<ItemStack> factory) {
this.material = material;
this.identity = new NamespacedKey("spigotignis", key);
this.title = title;
this.factory = factory;
}
public ItemStack prototype() {
var item = factory.get();
var meta = item.getItemMeta();
meta.displayName(Component.text(title, NamedTextColor.DARK_PURPLE));
item.setItemMeta(meta);
return item;
}
public ItemStack create(ItemToken token) {
var item = prototype();
var meta = item.getItemMeta();
meta.getPersistentDataContainer().set(identity, PersistentDataType.STRING,
EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
item.setItemMeta(meta);
return item;
}
public boolean offer(Player player, ItemToken token) {
for (var item : player.getInventory().getContents()) {
if (identify(item).filter(token::equals).isPresent()) return true;
}
if (identify(player.getItemOnCursor()).filter(token::equals).isPresent()) return true;
if (player.getInventory().firstEmpty() < 0) return false;
return player.getInventory().addItem(create(token)).isEmpty();
}
public Optional<ItemToken> identify(ItemStack item) {
if (item == null || item.getType() != material || !item.hasItemMeta()) return Optional.empty();
return EchoIdentity.decode(item.getItemMeta().getPersistentDataContainer().get(identity, PersistentDataType.STRING))
.map(token -> new ItemToken(token.owner(), token.receipt(), token.generation()));
}
}
@@ -12,14 +12,19 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
private MaskEquipmentListener maskEquipment;
private PearlProgress pearlProgress;
private DaggerCrafting daggerCrafting;
private DarknessProgress heartProgress;
private HeartCrafting heartCrafting;
private HeartEffects heartEffects;
private final java.util.concurrent.atomic.AtomicBoolean heartSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
private final java.util.concurrent.atomic.AtomicBoolean pearlSaveWarning = new java.util.concurrent.atomic.AtomicBoolean();
@Override
public void onEnable() {
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")));
initialization = StateService.open(new FileStateRepository(getDataFolder().toPath().resolve("state.bin")))
.thenCompose(HeartProgressState::prepare);
initialization.whenComplete((service, error) -> {
if (error != null) {
getLogger().severe("Ignis state unavailable; original data retained. Restore a verified backup before retrying.");
getLogger().severe("Ignis state initialization failed; original data retained. Check storage and validated recovery guidance before retrying.");
} else {
if (!isEnabled()) { service.close(); return; }
try {
@@ -58,13 +63,26 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(new DaggerUse(service::snapshot, daggerItems,
new DaggerCooldown(System::nanoTime), DaggerSafety::safe), this);
getServer().getScheduler().runTaskTimer(this, daggerCrafting, 1L, 20L);
var commands = new IgnisCommands(this, service, gameplay, maskTracker, pearlProgress);
heartProgress = new DarknessProgress(service, System::nanoTime);
var heartListener = new HeartProgressListener(this, heartProgress, this::reportHeartSave);
var heartItems = new HeartItems();
heartCrafting = new HeartCrafting(this, service, heartItems, gameplay);
heartEffects = new HeartEffects(this, service, heartItems);
getServer().getPluginManager().registerEvents(heartListener, this);
getServer().getPluginManager().registerEvents(heartCrafting, this);
getServer().getPluginManager().registerEvents(heartEffects, this);
getServer().getPluginManager().registerEvents(new HeartPlacement(heartItems), this);
getServer().getScheduler().runTaskTimer(this, heartListener, 1L, 1L);
getServer().getScheduler().runTaskTimer(this, heartListener::checkpoint, 100L, 100L);
getServer().getScheduler().runTaskTimer(this, heartCrafting, 1L, 20L);
getServer().getScheduler().runTaskTimer(this, heartEffects, 1L, 1L);
var commands = new IgnisCommands(this, service, gameplay, maskTracker, pearlProgress, heartProgress);
for (String name : java.util.List.of("ignis", "ignisadmin")) {
var command = java.util.Objects.requireNonNull(getCommand(name), "Missing command " + name);
command.setExecutor(commands);
command.setTabCompleter(commands);
}
getLogger().info("Ignis Echo, Mask and Dagger enabled. Heart remains in development.");
getLogger().info("Ignis Echo, Mask, Dagger and Heart enabled.");
});
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
service.close();
@@ -76,20 +94,31 @@ public final class SpigotIgnisPlugin extends JavaPlugin {
@Override
public void onDisable() {
try {
if (heartEffects != null) heartEffects.close();
if (heartCrafting != null) heartCrafting.close();
if (maskEffects != null) maskEffects.close();
if (maskEquipment != null) com.comphenix.protocol.ProtocolLibrary.getProtocolManager().removePacketListener(maskEquipment);
if (maskCrafting != null) maskCrafting.close();
if (daggerCrafting != null) daggerCrafting.close();
if (maskProgress != null) maskProgress.stop();
} finally {
var drain = pearlProgress == null ? CompletableFuture.<Void>completedFuture(null) : pearlProgress.checkpointAll();
drain.whenComplete((saved, failure) -> {
reportPearlSave(failure);
var pearlDrain = (pearlProgress == null ? CompletableFuture.<Void>completedFuture(null) : pearlProgress.checkpointAll())
.whenComplete((saved, failure) -> reportPearlSave(failure));
var heartDrain = (heartProgress == null ? CompletableFuture.<Void>completedFuture(null) : heartProgress.stopAll())
.whenComplete((saved, failure) -> reportHeartSave(failure));
CompletableFuture.allOf(pearlDrain, heartDrain).whenComplete((saved, failure) -> {
if (initialization != null) initialization.thenAccept(StateService::close);
});
}
}
private void reportHeartSave(Throwable failure) {
if (failure == null) heartSaveWarning.set(false);
else if (heartSaveWarning.compareAndSet(false, true)) {
getLogger().warning("Heart progress save failed; desired state remains in memory for retry while enabled.");
}
}
private void reportPearlSave(Throwable failure) {
if (failure == null) pearlSaveWarning.set(false);
else if (pearlSaveWarning.compareAndSet(false, true)) {
@@ -0,0 +1,32 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DarknessAdministrationTest {
@TempDir Path directory;
@Test
void editsFlushElapsedProgressAndRebaseRatherThanReplayingTheOldInterval() throws Exception {
for (boolean add : new boolean[]{false, true}) {
try (var states = StateService.open(new FileStateRepository(directory.resolve(add + ".bin"))).get()) {
var clock = new AtomicLong();
var tracker = new DarknessProgress(states, clock::get);
var id = UUID.randomUUID();
tracker.observe(id, true);
clock.set(100_000_000L);
tracker.observe(id, true);
tracker.edit(id, 500, add).get();
tracker.observe(id, true);
tracker.checkpoint(id).get();
assertEquals(add ? 600 : 500, states.snapshot().players().get(id).track(Track.HEART).progress());
tracker.interrupt(id);
tracker.checkpoint(id).get();
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
}
}
}
}
@@ -0,0 +1,74 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
class DarknessBatchTest {
@Test
void periodicCallsCoalesceButShutdownQueuesFreshInterruptionsBehindAnInflightSave() throws Exception {
var started = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var writes = new AtomicInteger();
var repository = new StateRepository() {
@Override public IgnisState load() { return IgnisState.empty(); }
@Override public void save(IgnisState state) throws java.io.IOException {
if (writes.incrementAndGet() == 1) {
started.countDown();
try {
if (!release.await(3, java.util.concurrent.TimeUnit.SECONDS)) throw new java.io.IOException("fixture timeout");
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new java.io.IOException(error);
}
}
}
};
try (var states = StateService.open(repository).get()) {
var clock = new AtomicLong();
var tracker = new DarknessProgress(states, clock::get);
var id = UUID.randomUUID();
tracker.observe(id, true);
clock.set(100_000_000L);
tracker.observe(id, true);
var first = tracker.checkpointAll();
try {
assertTrue(started.await(3, java.util.concurrent.TimeUnit.SECONDS));
clock.set(200_000_000L);
tracker.observe(id, true);
assertSame(first, tracker.checkpointAll());
var stopping = tracker.stopAll();
assertNotSame(first, stopping);
release.countDown();
stopping.get();
assertEquals(2, writes.get());
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
} finally { release.countDown(); }
}
}
@Test
void checkpointsAndShutdownUseOneWriteForAllRememberedPlayers() throws Exception {
var writes = new AtomicInteger();
var repository = new StateRepository() {
@Override public IgnisState load() { return IgnisState.empty(); }
@Override public void save(IgnisState state) { writes.incrementAndGet(); }
};
try (var states = StateService.open(repository).get()) {
var clock = new AtomicLong();
var tracker = new DarknessProgress(states, clock::get);
var players = java.util.stream.IntStream.range(0, 20).mapToObj(ignored -> UUID.randomUUID()).toList();
players.forEach(id -> tracker.observe(id, true));
clock.set(100_000_000L);
players.forEach(id -> tracker.observe(id, true));
tracker.checkpointAll().get();
assertEquals(1, writes.get());
for (var id : players) assertEquals(100, states.snapshot().players().get(id).track(Track.HEART).progress());
tracker.stopAll().get();
assertEquals(2, writes.get());
for (var id : players) assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
}
}
}
@@ -0,0 +1,105 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class DarknessProgressTest {
@TempDir Path directory;
@Test
void shutdownClearsUnfinishedStreakAndFailedResetRemainsRetryable() throws Exception {
var disk = new FileStateRepository(directory.resolve("shutdown.bin"));
var fail = new java.util.concurrent.atomic.AtomicBoolean(false);
StateRepository boundary = new StateRepository() {
@Override public IgnisState load() throws java.io.IOException { return disk.load(); }
@Override public void save(IgnisState state) throws java.io.IOException {
if (fail.get()) throw new java.io.IOException("Simulated storage failure");
disk.save(state);
}
};
var clock = new AtomicLong();
var id = UUID.randomUUID();
try (var states = StateService.open(boundary).get()) {
var tracker = new DarknessProgress(states, clock::get);
tracker.observe(id, true);
clock.set(500_000_000L);
tracker.observe(id, true);
tracker.checkpoint(id).get();
fail.set(true);
assertThrows(java.util.concurrent.ExecutionException.class, () -> tracker.stopAll().get());
assertEquals(500, states.snapshot().players().get(id).track(Track.HEART).progress());
fail.set(false);
tracker.checkpointAll().get();
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
clock.set(10_000_000_000L);
tracker.observe(id, true);
tracker.checkpointAll().get();
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
}
}
@Test
void queuedOldIntervalCannotRestoreProgressAfterInterruptionOrAdministratorReset() throws Exception {
for (boolean adminReset : new boolean[]{false, true}) {
var clock = new AtomicLong();
var entered = new java.util.concurrent.CountDownLatch(1);
var release = new java.util.concurrent.CountDownLatch(1);
var id = UUID.randomUUID();
try (var states = StateService.open(new FileStateRepository(directory.resolve(adminReset + ".bin"))).get()) {
var tracker = new DarknessProgress(states, clock::get);
tracker.observe(id, true);
clock.set(500_000_000L);
tracker.observe(id, true);
var blocker = states.update(state -> {
entered.countDown();
try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("Timed out"); }
catch (InterruptedException error) { Thread.currentThread().interrupt(); throw new IllegalStateException(error); }
return state;
});
try {
assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS));
if (adminReset) states.update(state -> ProgressAdministration.reset(state, id, java.util.Set.of(Track.HEART)));
var stale = tracker.checkpoint(id);
if (!adminReset) tracker.interrupt(id);
release.countDown();
blocker.get(); stale.get();
assertEquals(0, states.snapshot().players().getOrDefault(id, PlayerState.empty()).track(Track.HEART).progress());
tracker.observe(id, true);
clock.addAndGet(200_000_000L);
tracker.observe(id, true);
tracker.checkpoint(id).get();
assertEquals(200, states.snapshot().players().get(id).track(Track.HEART).progress());
} finally { release.countDown(); }
}
}
}
@Test
void savesContinuousProgressResetsInterruptionsAndRetainsCompletedUnlock() throws Exception {
var clock = new AtomicLong();
UUID id = UUID.randomUUID();
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
states.configure(1000, 313, 1000).get();
var tracker = new DarknessProgress(states, clock::get);
tracker.observe(id, true);
clock.set(500_000_000L);
tracker.observe(id, true);
tracker.checkpoint(id).get();
assertEquals(500, states.snapshot().players().getOrDefault(id, PlayerState.empty()).track(Track.HEART).progress());
tracker.interrupt(id);
tracker.checkpoint(id).get();
assertEquals(0, states.snapshot().players().get(id).track(Track.HEART).progress());
tracker.observe(id, true);
clock.addAndGet(1_000_000_000L);
tracker.observe(id, true);
tracker.checkpoint(id).get();
assertTrue(states.snapshot().players().get(id).track(Track.HEART).unlocked());
tracker.interrupt(id);
tracker.checkpoint(id).get();
assertTrue(states.snapshot().players().get(id).track(Track.HEART).unlocked());
}
}
}
@@ -0,0 +1,37 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
class DarknessStreakTest {
@Test
void administratorSeedCountsOnlyUntilAnUnfinishedStreakIsInterrupted() {
var time = new AtomicLong();
var streak = new DarknessStreak(time::get, 500);
assertEquals(500, streak.sample(true, 1000).millis());
time.set(200_000_000L);
assertEquals(700, streak.sample(true, 1000).millis());
assertEquals(0, streak.interrupt().millis());
time.set(900_000_000L);
assertEquals(0, streak.sample(true, 1000).millis());
assertThrows(IllegalArgumentException.class, () -> new DarknessStreak(time::get, -1));
}
@Test
void requiresOneUninterruptedSixHourStreakAndRetainsCompletedUnlock() {
long hour = 3_600_000;
var time = new AtomicLong();
var streak = new DarknessStreak(time::get);
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(true, 6 * hour));
time.set(3 * hour * 1_000_000);
assertEquals(new DarknessStreak.Progress(3 * hour, false), streak.sample(true, 6 * hour));
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(false, 6 * hour));
time.set(10 * hour * 1_000_000); // Time outside qualifying darkness earns nothing.
assertEquals(new DarknessStreak.Progress(0, false), streak.sample(true, 6 * hour));
time.set(16 * hour * 1_000_000);
assertEquals(new DarknessStreak.Progress(6 * hour, true), streak.sample(true, 6 * hour));
assertEquals(new DarknessStreak.Progress(6 * hour, true), streak.interrupt());
assertTrue(streak.sample(false, 12 * hour).complete(), "Higher thresholds do not revoke completion");
}
}
@@ -0,0 +1,17 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class HeartConditionsTest {
@Test
void darknessWorksEverywhereButNightOnlyWorksInNormalCycleWorlds() {
assertTrue(HeartConditions.active(true, true, false));
assertTrue(HeartConditions.active(true, false, false));
assertTrue(HeartConditions.active(true, false, true));
assertTrue(HeartConditions.active(false, true, true));
assertFalse(HeartConditions.active(false, true, false));
assertFalse(HeartConditions.active(false, false, true));
assertFalse(HeartConditions.active(false, false, false));
}
}
@@ -0,0 +1,54 @@
package games.dmg.spigotignis;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class HeartCraftingTest {
@TempDir Path directory;
@Test
void ingredientValidationCannotBeBypassedWithAnAuthenticatedPreview() throws Exception {
UUID owner = UUID.randomUUID(), receipt = UUID.randomUUID();
try (var states = StateService.open(new FileStateRepository(directory.resolve("state.bin"))).get()) {
states.update(state -> new TradableRewards(Track.HEART).issue(state, owner, receipt, true)).get();
var items = mock(HeartItems.class);
var product = mock(ItemStack.class);
when(items.identify(product)).thenReturn(java.util.Optional.of(new ItemToken(owner, receipt, 0)));
var player = mock(Player.class);
when(player.getUniqueId()).thenReturn(owner);
var recipe = mock(ShapedRecipe.class);
when(recipe.getKey()).thenReturn(HeartCrafting.RECIPE);
var inventory = mock(CraftingInventory.class);
when(inventory.getResult()).thenReturn(product);
when(inventory.getMatrix()).thenReturn(new ItemStack[9]);
var event = mock(CraftItemEvent.class);
when(event.getInventory()).thenReturn(inventory);
when(event.getRecipe()).thenReturn(recipe);
when(event.getWhoClicked()).thenReturn(player);
new HeartCrafting(null, states, items, null).onCraft(event);
verify(event).setCancelled(true);
verify(inventory).setResult(null);
verify(inventory, never()).setMatrix(any());
}
}
@Test
void recipeUsesOrthogonalBottlesAndCornerHealingWithCentralHeart() {
var recipe = mock(ShapedRecipe.class);
var bottle = mock(RecipeChoice.ExactChoice.class);
var healing = mock(RecipeChoice.ExactChoice.class);
HeartCrafting.configureRecipe(recipe, bottle, healing);
verify(recipe).shape("PBP", "BCB", "PBP");
verify(recipe).setIngredient('B', bottle);
verify(recipe).setIngredient('P', healing);
verify(recipe).setIngredient('C', Material.CREAKING_HEART);
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigotignis;
import static org.mockito.Mockito.*;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
class HeartEffectsTest {
@Test
void ticksEachPlayerOnceAndWithdrawsBeforeDiscardingSessionOwnership() {
var plugin = mock(JavaPlugin.class);
var server = mock(Server.class);
when(plugin.getServer()).thenReturn(server);
var player = mock(Player.class);
var id = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
var regeneration = mock(HeartRegeneration.class);
var channel = mock(HeartRegeneration.Channel.class);
var effects = new HeartEffects(plugin, regeneration, ignored -> true, ignored -> channel);
effects.run();
verify(regeneration, times(1)).tick(id, true, channel);
effects.close();
var ordered = inOrder(regeneration);
ordered.verify(regeneration).tick(id, true, channel);
ordered.verify(regeneration).tick(id, false, channel);
ordered.verify(regeneration).forget(id);
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class HeartEligibilityTest {
@Test
void inventoryPresenceNeedsPersonalUnlockValidIssuerAndDarknessOrNormalNight() {
UUID issuer = UUID.randomUUID(), holder = UUID.randomUUID(), receipt = UUID.randomUUID();
var token = new ItemToken(issuer, receipt, 0);
var state = new TradableRewards(Track.HEART).issue(IgnisState.empty(), issuer, receipt, true);
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), true, true, true));
state = ProgressAdministration.progress(state, holder, Track.HEART, state.limits().darknessMillis(), false);
assertTrue(HeartEligibility.eligible(state, holder, List.of(token, token), false, true, true));
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), false, false, true));
assertTrue(HeartEligibility.eligible(state, holder, List.of(token), true, false, false));
assertFalse(HeartEligibility.eligible(state, holder, List.of(), true, true, true));
state = ProgressAdministration.reset(state, issuer, Set.of(Track.HEART));
assertFalse(HeartEligibility.eligible(state, holder, List.of(token), true, true, true));
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.junit.jupiter.api.Test;
class HeartEnvironmentTest {
@Test
void totalLightMustBeZeroAndUnloadedChunksAreNotQueried() {
var world = mock(World.class);
var block = mock(Block.class);
var location = new Location(world, -1, 64, 32);
assertFalse(HeartEnvironment.dark(location));
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
when(world.isChunkLoaded(-1, 2)).thenReturn(true);
when(world.getBlockAt(-1, 64, 32)).thenReturn(block);
when(block.getLightLevel()).thenReturn((byte) 0);
assertTrue(HeartEnvironment.dark(location));
when(block.getLightLevel()).thenReturn((byte) 1);
assertFalse(HeartEnvironment.dark(location));
assertFalse(HeartEnvironment.dark(null));
assertFalse(HeartEnvironment.dark(new Location(null, 0, 0, 0)));
}
}
@@ -0,0 +1,67 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.OminousBottleMeta;
import org.bukkit.inventory.meta.PotionMeta;
import org.bukkit.potion.PotionType;
import org.junit.jupiter.api.Test;
class HeartIngredientsTest {
@Test
void validatesAllNineRecipePositionsRatherThanOnlyMaterialNames() {
var bottle = mock(ItemStack.class);
var omen = mock(OminousBottleMeta.class);
when(bottle.getType()).thenReturn(Material.OMINOUS_BOTTLE);
when(bottle.getItemMeta()).thenReturn(omen);
when(omen.hasAmplifier()).thenReturn(true);
when(omen.getAmplifier()).thenReturn(4);
var potion = mock(ItemStack.class);
var meta = mock(PotionMeta.class);
when(potion.getType()).thenReturn(Material.LINGERING_POTION);
when(potion.getItemMeta()).thenReturn(meta);
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
var center = mock(ItemStack.class);
when(center.getType()).thenReturn(Material.CREAKING_HEART);
ItemStack[] grid = {potion, bottle, potion, bottle, center, bottle, potion, bottle, potion};
assertTrue(HeartIngredients.matrix(grid));
grid[0] = bottle;
assertFalse(HeartIngredients.matrix(grid));
grid[0] = potion;
grid[4] = potion;
assertFalse(HeartIngredients.matrix(grid));
assertFalse(HeartIngredients.matrix(new ItemStack[4]));
assertFalse(HeartIngredients.matrix(null));
}
@Test
void requiresBottleFiveAndLingeringInstantHealthTwoWithoutAddedEffects() {
var bottle = mock(ItemStack.class);
var omen = mock(OminousBottleMeta.class);
when(bottle.getType()).thenReturn(Material.OMINOUS_BOTTLE);
when(bottle.getItemMeta()).thenReturn(omen);
when(omen.hasAmplifier()).thenReturn(true);
when(omen.getAmplifier()).thenReturn(4); // Zero-based amplifier: level V.
assertTrue(HeartIngredients.bottle(bottle));
when(omen.getAmplifier()).thenReturn(3);
assertFalse(HeartIngredients.bottle(bottle));
var potion = mock(ItemStack.class);
var meta = mock(PotionMeta.class);
when(potion.getType()).thenReturn(Material.LINGERING_POTION);
when(potion.getItemMeta()).thenReturn(meta);
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
assertTrue(HeartIngredients.healing(potion));
when(meta.hasCustomEffects()).thenReturn(true);
assertFalse(HeartIngredients.healing(potion));
when(meta.hasCustomEffects()).thenReturn(false);
when(meta.getBasePotionType()).thenReturn(PotionType.HEALING);
assertFalse(HeartIngredients.healing(potion));
when(meta.getBasePotionType()).thenReturn(PotionType.STRONG_HEALING);
when(potion.getType()).thenReturn(Material.SPLASH_POTION);
assertFalse(HeartIngredients.healing(potion));
assertFalse(HeartIngredients.bottle(null));
assertFalse(HeartIngredients.healing(null));
}
}
@@ -0,0 +1,40 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CompletionException;
import java.util.concurrent.RejectedExecutionException;
import org.junit.jupiter.api.Test;
class HeartInitializationTest {
@Test
void initializationAcknowledgesOnlyDurablyClearedUnfinishedProgress() throws Exception {
var id = UUID.randomUUID();
class Repository implements StateRepository {
IgnisState saved = ProgressAdministration.progress(IgnisState.empty(), id, Track.HEART, 123, false);
@Override public IgnisState load() { return saved; }
@Override public void save(IgnisState state) { saved = state; }
}
var repository = new Repository();
try (var service = StateService.open(repository).get()) {
assertSame(service, HeartProgressState.prepare(service).get());
assertEquals(0, service.snapshot().players().get(id).track(Track.HEART).progress());
assertEquals(0, repository.saved.players().get(id).track(Track.HEART).progress());
}
}
@Test
void failedNormalizationKeepsOriginalStateAndClosesTheWorker() throws Exception {
var original = ProgressAdministration.progress(IgnisState.empty(), UUID.randomUUID(), Track.HEART, 123, false);
var repository = new StateRepository() {
@Override public IgnisState load() { return original; }
@Override public void save(IgnisState state) throws IOException { throw new IOException("fixture storage failure"); }
};
try (var service = StateService.open(repository).get()) {
assertThrows(CompletionException.class, () -> HeartProgressState.prepare(service).join());
assertEquals(original, service.snapshot());
var failure = assertThrows(CompletionException.class, () -> service.update(state -> state).join());
assertInstanceOf(RejectedExecutionException.class, failure.getCause());
}
}
}
@@ -0,0 +1,34 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test;
class HeartItemsTest {
@Test
void onlyHeartMaterialAndItsOwnReceiptNamespaceAuthenticate() {
var item = mock(ItemStack.class);
var meta = mock(ItemMeta.class);
var tags = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.CREAKING_HEART);
when(item.hasItemMeta()).thenReturn(true);
when(item.getItemMeta()).thenReturn(meta);
when(meta.getPersistentDataContainer()).thenReturn(tags);
var items = new HeartItems();
assertTrue(items.identify(item).isEmpty());
var token = new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 2);
when(tags.get(new NamespacedKey("spigotignis", "heart_identity"), PersistentDataType.STRING))
.thenReturn(EchoIdentity.encode(new EchoToken(token.issuer(), token.receipt(), token.generation())));
assertEquals(token, items.identify(item).orElseThrow());
assertTrue(new DaggerItems().identify(item).isEmpty());
when(item.getType()).thenReturn(Material.OMINOUS_TRIAL_KEY);
assertTrue(items.identify(item).isEmpty());
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigotignis;
import static org.mockito.Mockito.*;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
class HeartPlacementTest {
@Test
void issuedCopiesCannotBecomeBlocksEvenWhenTheirReceiptIsRevoked() {
var items = mock(HeartItems.class);
var item = mock(ItemStack.class);
var event = mock(BlockPlaceEvent.class);
when(event.getItemInHand()).thenReturn(item);
when(items.identify(item)).thenReturn(Optional.of(new ItemToken(UUID.randomUUID(), UUID.randomUUID(), 0)));
new HeartPlacement(items).onPlace(event);
verify(event).setCancelled(true);
}
@Test
void ordinaryCreakingHeartsRemainPlaceable() {
var items = mock(HeartItems.class);
var item = mock(ItemStack.class);
var event = mock(BlockPlaceEvent.class);
when(event.getItemInHand()).thenReturn(item);
when(items.identify(item)).thenReturn(Optional.empty());
new HeartPlacement(items).onPlace(event);
verify(event, never()).setCancelled(anyBoolean());
}
}
@@ -0,0 +1,68 @@
package games.dmg.spigotignis;
import static org.mockito.Mockito.*;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import org.bukkit.Server;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
class HeartProgressListenerTest {
@Test
void observesOnlinePlayersAndInterruptsOnlyUncancelledPositiveDamage() {
var plugin = mock(JavaPlugin.class);
var server = mock(Server.class);
var player = mock(Player.class);
var id = UUID.randomUUID();
when(plugin.getServer()).thenReturn(server);
when(player.getUniqueId()).thenReturn(id);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
var progress = mock(DarknessProgress.class);
when(progress.checkpoint(id)).thenReturn(CompletableFuture.completedFuture(IgnisState.empty()));
var listener = new HeartProgressListener(plugin, progress, ignored -> true, error -> { throw new AssertionError(error); });
listener.run();
verify(progress).observe(id, true);
var damage = mock(EntityDamageEvent.class);
when(damage.getEntity()).thenReturn(player);
when(damage.getFinalDamage()).thenReturn(1.0);
when(damage.isCancelled()).thenReturn(true);
listener.onDamage(damage);
verify(progress, never()).interrupt(id);
when(damage.isCancelled()).thenReturn(false);
when(damage.getFinalDamage()).thenReturn(0.0);
listener.onDamage(damage);
verify(progress, never()).interrupt(id);
when(damage.getFinalDamage()).thenReturn(1.0);
listener.onDamage(damage);
verify(progress).interrupt(id);
verify(progress).checkpoint(id);
}
@Test
void enteringLightAndDisconnectingInterruptButCancelledMovementDoesNot() {
var player = mock(Player.class);
var id = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(id);
var progress = mock(DarknessProgress.class);
when(progress.checkpoint(id)).thenReturn(CompletableFuture.completedFuture(IgnisState.empty()));
var listener = new HeartProgressListener(null, progress, ignored -> false, error -> { throw new AssertionError(error); });
var move = mock(PlayerMoveEvent.class);
when(move.getPlayer()).thenReturn(player);
when(move.getTo()).thenReturn(new Location(null, 0, 0, 0));
when(move.isCancelled()).thenReturn(true);
listener.onMove(move);
verifyNoInteractions(progress);
when(move.isCancelled()).thenReturn(false);
listener.onMove(move);
verify(progress).interrupt(id);
var quit = mock(PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(player);
listener.onQuit(quit);
verify(progress, times(2)).interrupt(id);
verify(progress).checkpoint(id);
}
}
@@ -0,0 +1,21 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class HeartProgressStateTest {
@Test
void restartClearsOnlyUnfinishedDarknessWithoutRevokingCompletedRewardsOrOtherTracks() {
UUID unfinished = UUID.randomUUID(), finished = UUID.randomUUID(), receipt = UUID.randomUUID();
var state = ProgressAdministration.progress(IgnisState.empty(), unfinished, Track.HEART, 1000, false);
state = ProgressAdministration.progress(state, unfinished, Track.DAGGER, 11, false);
state = new TradableRewards(Track.HEART).issue(state, finished, receipt, true);
var restarted = HeartProgressState.restart(state);
assertEquals(0, restarted.players().get(unfinished).track(Track.HEART).progress());
assertEquals(11, restarted.players().get(unfinished).track(Track.DAGGER).progress());
assertEquals(state.players().get(finished), restarted.players().get(finished));
assertEquals(1000, state.players().get(unfinished).track(Track.HEART).progress());
assertEquals(state.limits(), restarted.limits());
}
}
@@ -0,0 +1,129 @@
package games.dmg.spigotignis;
import static org.junit.jupiter.api.Assertions.*;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
class HeartRegenerationTest {
@Test
void shutdownRestoresWeakerEffectsEvenWhenDisabledListenersReceiveNoEvents() {
var controller = new HeartRegeneration(() -> 0L);
var id = UUID.randomUUID();
class Channel implements HeartRegeneration.Channel {
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
boolean events = true;
@Override public HeartRegeneration.Effect current() { return effect; }
@Override public void apply(HeartRegeneration.Effect next) {
effect = next;
if (events) controller.changed(id, true, true);
}
@Override public void remove() { effect = null; }
}
var channel = new Channel();
controller.tick(id, true, channel);
channel.events = false;
controller.tick(id, false, channel);
assertEquals(new HeartRegeneration.Effect(0, 200, false, true, true), channel.effect);
}
@Test
void endingSessionDropsOwnershipRatherThanRestoringAnOfflineAgedBackupLater() {
var controller = new HeartRegeneration(() -> 0L);
var id = UUID.randomUUID();
class Channel implements HeartRegeneration.Channel {
HeartRegeneration.Effect effect;
int removals;
@Override public HeartRegeneration.Effect current() { return effect; }
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
@Override public void remove() { removals++; } // External plugin vetoes removal.
}
var channel = new Channel();
controller.tick(id, true, channel);
controller.tick(id, false, channel);
controller.forget(id);
controller.tick(id, false, channel);
assertEquals(1, channel.removals);
}
@Test
void cancellationAndStrongerExternalEffectsTakePrecedenceAndInfiniteBackupStaysInfinite() {
var controller = new HeartRegeneration(() -> 1000L);
var id = UUID.randomUUID();
class Channel implements HeartRegeneration.Channel {
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, -1, false, true, true);
boolean allowApply, allowRemove;
int removals;
@Override public HeartRegeneration.Effect current() { return effect; }
@Override public void apply(HeartRegeneration.Effect next) {
if (allowApply) { effect = next; controller.changed(id, true, true); }
}
@Override public void remove() {
if (allowRemove) { removals++; effect = null; controller.changed(id, true, false); }
}
}
var channel = new Channel();
controller.tick(id, true, channel);
controller.tick(id, false, channel);
assertEquals(0, channel.effect.amplifier());
channel.allowApply = true;
controller.tick(id, true, channel);
controller.tick(id, false, channel);
assertEquals(1, channel.effect.amplifier(), "Cancelled removal is respected");
channel.allowRemove = true;
controller.tick(id, false, channel);
assertEquals(-1, channel.effect.ticks());
channel.effect = new HeartRegeneration.Effect(2, 400, false, true, true);
controller.changed(id, true, true);
controller.tick(id, true, channel);
controller.tick(id, false, channel);
assertEquals(2, channel.effect.amplifier());
assertEquals(1, channel.removals);
}
@Test
void anExternalClearCannotResurrectTheWeakerEffectOnLaterUnequip() {
var controller = new HeartRegeneration(() -> 0L);
var id = UUID.randomUUID();
class Channel implements HeartRegeneration.Channel {
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
@Override public HeartRegeneration.Effect current() { return effect; }
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
@Override public void remove() { effect = null; controller.changed(id, true, false); }
}
var channel = new Channel();
controller.tick(id, true, channel);
channel.effect = null;
controller.changed(id, true, false); // Milk or another plugin cleared regeneration.
controller.tick(id, true, channel);
controller.tick(id, false, channel);
assertNull(channel.effect);
}
@Test
void overlaysRegenerationTwoAndRestoresOnlyTheRemainingWeakerEffect() {
var clock = new AtomicLong();
var controller = new HeartRegeneration(clock::get);
var id = UUID.randomUUID();
class Channel implements HeartRegeneration.Channel {
HeartRegeneration.Effect effect = new HeartRegeneration.Effect(0, 200, false, true, true);
@Override public HeartRegeneration.Effect current() { return effect; }
@Override public void apply(HeartRegeneration.Effect next) { effect = next; controller.changed(id, true, true); }
@Override public void remove() { effect = null; controller.changed(id, true, false); }
}
var channel = new Channel();
controller.tick(id, true, channel);
assertEquals(1, channel.effect.amplifier());
assertEquals(40, channel.effect.ticks());
for (int tick = 10; tick <= 100; tick += 10) {
clock.set(tick);
var old = channel.effect;
channel.effect = new HeartRegeneration.Effect(old.amplifier(), old.ticks() - 10, old.ambient(), old.particles(), old.icon());
controller.tick(id, true, channel);
}
controller.tick(id, false, channel);
assertEquals(new HeartRegeneration.Effect(0, 100, false, true, true), channel.effect);
controller.tick(id, false, channel);
assertEquals(0, channel.effect.amplifier(), "Restored external regeneration is not claimed as ours");
}
}
@@ -10,6 +10,35 @@ import org.junit.jupiter.api.Test;
class IgnisCommandsTest {
@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory;
@Test
void administrativeHeartSetUsesTheTrackerRebaseBarrier() throws Exception {
var plugin = mock(JavaPlugin.class);
when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger());
var sender = mock(CommandSender.class);
when(sender.hasPermission("ignis.admin")).thenReturn(true);
var saved = new java.util.concurrent.CountDownLatch(1);
doAnswer(call -> { saved.countDown(); return null; }).when(sender).sendMessage("Ignis change saved.");
var command = mock(Command.class);
when(command.getName()).thenReturn("ignisadmin");
var dispatch = mock(EchoGameplay.class);
doAnswer(call -> { ((Runnable) call.getArgument(0)).run(); return null; }).when(dispatch).onServer(any());
var owner = java.util.UUID.randomUUID();
try (var states = StateService.open(new FileStateRepository(directory.resolve("heart.bin"))).get()) {
states.update(state -> ProgressAdministration.progress(state, owner, Track.HEART, 0, false)).get();
var clock = new java.util.concurrent.atomic.AtomicLong();
var tracker = new DarknessProgress(states, clock::get);
tracker.observe(owner, true);
clock.set(100_000_000L);
tracker.observe(owner, true);
var commands = new IgnisCommands(plugin, states, dispatch, null, null, tracker);
commands.onCommand(sender, command, "ignisadmin", new String[]{"progress", "set", owner.toString(), "heart", "2"});
assertTrue(saved.await(3, java.util.concurrent.TimeUnit.SECONDS));
tracker.observe(owner, true);
tracker.checkpoint(owner).get();
assertEquals(2, states.snapshot().players().get(owner).track(Track.HEART).progress());
}
}
@Test
void administrativePearlSetFlushesOlderCreditsBeforeOverwritingCounter() throws Exception {
var plugin = mock(JavaPlugin.class);
@@ -58,7 +87,7 @@ class IgnisCommandsTest {
assertTrue(states.snapshot().players().get(owner).track(Track.MASK).unlocked());
assertEquals(100, states.snapshot().limits().invisibilityMillis());
assertTrue(handler.onTabComplete(sender, command, "ignisadmin", new String[]{""}).contains("inspect"));
assertEquals(java.util.List.of("dagger", "echo", "mask"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
assertEquals(java.util.List.of("dagger", "echo", "heart", "mask"), handler.onTabComplete(sender, command, "ignisadmin", new String[]{"give", owner.toString(), ""}));
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "mask"});
states.update(state -> state).get();
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.MASK));
@@ -66,6 +95,10 @@ class IgnisCommandsTest {
states.update(state -> state).get();
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.DAGGER));
assertTrue(states.snapshot().players().get(owner).track(Track.DAGGER).unlocked());
handler.onCommand(sender, command, "ignisadmin", new String[]{"give", owner.toString(), "heart"});
states.update(state -> state).get();
assertTrue(states.snapshot().players().get(owner).pendingRewards().containsValue(Track.HEART));
assertTrue(states.snapshot().players().get(owner).track(Track.HEART).unlocked());
}
}