feat(stealth): earn and equip the Eye of True Seeing
Add independent eight-hour Night Vision progression, save-gated crafting and holder eligibility, authenticated helmet items and guarded inventory movement. Verify source attribution, native item/recipe/event adapters and actual plugin lifecycle. Drain final state asynchronously and reject stale lifecycle callbacks. Per-viewer revelation remains the next story; no deployment is included.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.Keyed;
|
||||
import org.bukkit.Material;
|
||||
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.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
|
||||
/** Personal crafting unlock for the transferable Eye. Vanilla owns ingredient consumption. */
|
||||
public final class EyeCrafting implements Listener {
|
||||
public static final NamespacedKey RECIPE_KEY = new NamespacedKey("spigotstealth", "eye_of_true_seeing");
|
||||
private final EyeItems items;
|
||||
private final EyeProgressionService progression;
|
||||
|
||||
public EyeCrafting(EyeProgressionService progression, EyeItems items) {
|
||||
this.progression = Objects.requireNonNull(progression, "progression");
|
||||
this.items = Objects.requireNonNull(items, "items");
|
||||
}
|
||||
|
||||
public ShapedRecipe recipe() {
|
||||
return new ShapedRecipe(RECIPE_KEY, items.create()).shape("NNN", "NEN", "NNN")
|
||||
.setIngredient('N', Material.NETHERITE_BLOCK).setIngredient('E', Material.ENDER_EYE);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCraft(org.bukkit.event.inventory.CraftItemEvent event) {
|
||||
if (event.isCancelled() || !matches(event.getRecipe())) { return; }
|
||||
if (!(event.getWhoClicked() instanceof Player player) || !progression.isUnlocked(player.getUniqueId())
|
||||
|| !accepts(event.getInventory().getMatrix()) || !items.isEye(event.getInventory().getResult())
|
||||
|| event.getInventory().getResult().getAmount() != 1) {
|
||||
event.setCancelled(true);
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||
public void onCrafter(org.bukkit.event.block.CrafterCraftEvent event) {
|
||||
if (matches(event.getRecipe())) { event.setCancelled(true); }
|
||||
}
|
||||
|
||||
private static boolean matches(org.bukkit.inventory.Recipe recipe) {
|
||||
return recipe instanceof Keyed keyed && RECIPE_KEY.equals(keyed.getKey());
|
||||
}
|
||||
|
||||
private static boolean accepts(org.bukkit.inventory.ItemStack[] matrix) {
|
||||
if (matrix.length != 9) { return false; }
|
||||
for (int index = 0; index < matrix.length; index++) {
|
||||
if (matrix[index] == null || matrix[index].getAmount() <= 0
|
||||
|| matrix[index].getType() != (index == 4 ? Material.ENDER_EYE : Material.NETHERITE_BLOCK)) { return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onPrepare(PrepareItemCraftEvent event) {
|
||||
if (!(event.getInventory().getRecipe() instanceof Keyed keyed) || !RECIPE_KEY.equals(keyed.getKey())) { return; }
|
||||
if (!(event.getView().getPlayer() instanceof Player player) || !progression.isUnlocked(player.getUniqueId())) {
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
/** Player-specific eligibility and inventory movement; never invokes native Ender Eye use. */
|
||||
public final class EyeEquipment implements Listener {
|
||||
private final EyeProgressionService progression;
|
||||
private final EyeItems items;
|
||||
|
||||
public EyeEquipment(EyeProgressionService progression, EyeItems items) {
|
||||
this.progression = Objects.requireNonNull(progression, "progression");
|
||||
this.items = Objects.requireNonNull(items, "items");
|
||||
}
|
||||
|
||||
public boolean isEligibleWearer(Player player) {
|
||||
return progression.isUnlocked(player.getUniqueId()) && items.isEye(player.getInventory().getHelmet());
|
||||
}
|
||||
|
||||
// Interact-in-air may start with block use denied; respect item-use denial rather than isCancelled().
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void onUse(PlayerInteractEvent event) {
|
||||
if (!event.getAction().isRightClick() || event.useItemInHand() == Event.Result.DENY || !items.isEye(event.getItem())) { return; }
|
||||
event.setUseItemInHand(Event.Result.DENY);
|
||||
event.setUseInteractedBlock(Event.Result.DENY);
|
||||
Player player = event.getPlayer();
|
||||
if (player.isDead() || !progression.isUnlocked(player.getUniqueId())
|
||||
|| (event.getHand() != EquipmentSlot.HAND && event.getHand() != EquipmentSlot.OFF_HAND)) { return; }
|
||||
var inventory = player.getInventory();
|
||||
var helmet = inventory.getHelmet();
|
||||
if (helmet != null && helmet.getAmount() > 0 && !helmet.getType().isAir()) { return; }
|
||||
var held = inventory.getItem(event.getHand());
|
||||
if (!items.isEye(held)) { return; }
|
||||
var equipped = held.clone();
|
||||
equipped.setAmount(1);
|
||||
var remaining = held.clone();
|
||||
remaining.setAmount(held.getAmount() - 1);
|
||||
inventory.setItem(event.getHand(), remaining.getAmount() == 0 ? null : remaining);
|
||||
inventory.setHelmet(equipped);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
/** Creates and identifies the transferable Eye; eligibility belongs to the holder's durable progression. */
|
||||
public final class EyeItems {
|
||||
private static final NamespacedKey IDENTITY = new NamespacedKey("spigotstealth", "eye_of_true_seeing");
|
||||
|
||||
public ItemStack create() {
|
||||
var eye = new ItemStack(Material.ENDER_EYE);
|
||||
var meta = eye.getItemMeta();
|
||||
meta.displayName(Component.text("Eye of True Seeing"));
|
||||
meta.getPersistentDataContainer().set(IDENTITY, PersistentDataType.BYTE, (byte) 1);
|
||||
meta.setMaxStackSize(1);
|
||||
var equippable = meta.getEquippable();
|
||||
equippable.setSlot(EquipmentSlot.HEAD);
|
||||
equippable.setDispensable(false);
|
||||
equippable.setEquipOnInteract(false);
|
||||
equippable.setDamageOnHurt(false);
|
||||
meta.setEquippable(equippable);
|
||||
eye.setItemMeta(meta);
|
||||
return eye;
|
||||
}
|
||||
|
||||
public boolean isEye(ItemStack item) {
|
||||
return item != null && item.getAmount() > 0 && item.getType() == Material.ENDER_EYE && item.hasItemMeta()
|
||||
&& Byte.valueOf((byte) 1).equals(item.getItemMeta().getPersistentDataContainer().get(IDENTITY, PersistentDataType.BYTE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
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.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
|
||||
/** Night Vision potion-event adapter; progression and persistence are owned by the service. */
|
||||
public final class EyePotionListener implements Listener {
|
||||
private final EyeProgressionService progression;
|
||||
private final Consumer<Throwable> saveFailure;
|
||||
private final Map<UUID, PotionEffect> expectedEffects = new HashMap<>();
|
||||
|
||||
public EyePotionListener(EyeProgressionService progression, Consumer<Throwable> saveFailure) {
|
||||
this.progression = Objects.requireNonNull(progression, "progression");
|
||||
this.saveFailure = Objects.requireNonNull(saveFailure, "saveFailure");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onPotionEffect(EntityPotionEffectEvent event) {
|
||||
if (event.isCancelled() || !(event.getEntity() instanceof Player player)
|
||||
|| !PotionEffectType.NIGHT_VISION.equals(event.getModifiedType())) {
|
||||
return;
|
||||
}
|
||||
if (event.getAction() == EntityPotionEffectEvent.Action.CHANGED && !replacesActiveEffect(event)) {
|
||||
return;
|
||||
}
|
||||
boolean addedOrChanged = event.getAction() == EntityPotionEffectEvent.Action.ADDED
|
||||
|| event.getAction() == EntityPotionEffectEvent.Action.CHANGED;
|
||||
if (addedOrChanged && event.getCause() == EntityPotionEffectEvent.Cause.POTION_DRINK) {
|
||||
if (!continuesEffect(expectedEffects.get(player.getUniqueId()), event.getOldEffect())) {
|
||||
progression.abandon(player.getUniqueId());
|
||||
}
|
||||
expectedEffects.put(player.getUniqueId(), event.getNewEffect());
|
||||
observe(progression.begin(player.getUniqueId()));
|
||||
} else {
|
||||
finish(player.getUniqueId(), event.getOldEffect());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
finish(event.getPlayer().getUniqueId(), event.getPlayer().getPotionEffect(PotionEffectType.NIGHT_VISION));
|
||||
}
|
||||
|
||||
/** Server-thread observation; a missing/unattributed effect cannot inherit an old drink interval. */
|
||||
public void checkpoint(Function<UUID, Player> onlinePlayer) {
|
||||
for (var id : progression.activePlayerIds()) {
|
||||
Player player = onlinePlayer.apply(id);
|
||||
var actual = player == null || !player.isOnline() || player.isDead()
|
||||
? null : player.getPotionEffect(PotionEffectType.NIGHT_VISION);
|
||||
var expected = expectedEffects.get(id);
|
||||
if (!continuesEffect(expected, actual) || (!actual.isInfinite() && actual.getDuration() <= 0)) {
|
||||
expectedEffects.remove(id);
|
||||
progression.abandon(id); // Discard the unobserved tail, never attribute it to the old source.
|
||||
} else {
|
||||
expectedEffects.put(id, actual);
|
||||
observe(progression.checkpoint(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(UUID id, PotionEffect finalEffect) {
|
||||
var expected = expectedEffects.remove(id);
|
||||
if (continuesEffect(expected, finalEffect)) { observe(progression.stop(id)); }
|
||||
else { progression.abandon(id); }
|
||||
}
|
||||
|
||||
private static boolean continuesEffect(PotionEffect expected, PotionEffect actual) {
|
||||
return actual != null && expected != null && actual.getAmplifier() == expected.getAmplifier()
|
||||
&& (expected.isInfinite() ? actual.isInfinite()
|
||||
: !actual.isInfinite() && actual.getDuration() <= expected.getDuration());
|
||||
}
|
||||
|
||||
private void observe(CompletableFuture<Void> saved) {
|
||||
saved.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) { saveFailure.accept(failure); }
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean replacesActiveEffect(EntityPotionEffectEvent event) {
|
||||
if (!event.isOverride() || event.getOldEffect() == null || event.getNewEffect() == null) { return false; }
|
||||
var previous = event.getOldEffect();
|
||||
var next = event.getNewEffect();
|
||||
// Native update can return true for particles/icons alone. Hidden weaker effects are not active yet.
|
||||
return next.getAmplifier() > previous.getAmplifier()
|
||||
|| (next.getAmplifier() == previous.getAmplifier() && !previous.isInfinite()
|
||||
&& (next.isInfinite() || next.getDuration() > previous.getDuration()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Independent durable Eye of True Seeing progress. Active potion intervals are never resumed from disk. */
|
||||
public record EyeProgress(long accumulatedMillis, boolean unlocked, Map<String, Object> unknownFields) {
|
||||
public EyeProgress {
|
||||
if (accumulatedMillis < 0) { throw new IllegalArgumentException("Eye progress cannot be negative"); }
|
||||
unknownFields = Map.copyOf(unknownFields);
|
||||
}
|
||||
public EyeProgress(long accumulatedMillis, boolean unlocked) { this(accumulatedMillis, unlocked, Map.of()); }
|
||||
public static EyeProgress empty() { return new EyeProgress(0, false); }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** Server-thread qualifying intervals; durable rewards are acknowledged by the existing I/O executor. */
|
||||
public final class EyeProgressionService {
|
||||
public static final Duration UNLOCK_THRESHOLD = Duration.ofHours(8);
|
||||
private record Interval(long startedNanos, long creditedMillis) { }
|
||||
private final StealthStateManager states;
|
||||
private final LongSupplier nanoTime;
|
||||
private final Consumer<UUID> unlocked;
|
||||
private final Map<UUID, Interval> active = new HashMap<>();
|
||||
private final Set<UUID> announced = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** The notification callback must marshal Bukkit work onto the server thread. */
|
||||
public EyeProgressionService(StealthStateManager states, LongSupplier nanoTime, Consumer<UUID> unlocked) {
|
||||
this.states = Objects.requireNonNull(states, "states");
|
||||
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
|
||||
this.unlocked = Objects.requireNonNull(unlocked, "unlocked");
|
||||
states.durableSnapshot().eyes().forEach((id, progress) -> {
|
||||
if (progress.unlocked()) { announced.add(id); }
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> begin(UUID id) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
if (active.containsKey(id)) { return checkpoint(id); }
|
||||
active.put(id, new Interval(nanoTime.getAsLong(), 0));
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> checkpoint(UUID id) {
|
||||
Interval interval = active.get(id);
|
||||
if (interval == null) { return CompletableFuture.completedFuture(null); }
|
||||
// Keep the original start: repeated checkpoints must not discard fractional milliseconds.
|
||||
long total = Math.max(interval.creditedMillis(), Math.max(0, nanoTime.getAsLong() - interval.startedNanos()) / 1_000_000);
|
||||
long elapsed = total - interval.creditedMillis();
|
||||
if (elapsed == 0) { return CompletableFuture.completedFuture(null); }
|
||||
active.put(id, new Interval(interval.startedNanos(), total));
|
||||
return states.update(state -> {
|
||||
EyeProgress previous = state.eyeProgress(id);
|
||||
long accumulated = Math.addExact(previous.accumulatedMillis(), elapsed);
|
||||
return state.withEyeProgress(id, new EyeProgress(accumulated,
|
||||
previous.unlocked() || accumulated >= UNLOCK_THRESHOLD.toMillis(), previous.unknownFields()));
|
||||
}).thenRun(() -> {
|
||||
if (isUnlocked(id) && announced.add(id)) { unlocked.accept(id); }
|
||||
});
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> stop(UUID id) {
|
||||
CompletableFuture<Void> saved = checkpoint(id);
|
||||
active.remove(id);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> stopAll() {
|
||||
return CompletableFuture.allOf(activePlayerIds().stream().map(this::stop).toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
/** Defensive offline sweep: do not invent credit for the unobserved interval after the last checkpoint. */
|
||||
public void abandon(UUID id) { active.remove(id); }
|
||||
public boolean isUnlocked(UUID id) { return states.durableSnapshot().eyeProgress(id).unlocked(); }
|
||||
public Set<UUID> activePlayerIds() { return Set.copyOf(active.keySet()); }
|
||||
}
|
||||
@@ -9,11 +9,18 @@ import java.util.UUID;
|
||||
public record PersistentStealthState(
|
||||
Map<UUID, PlayerStealthState> players,
|
||||
SleepCountPolicy sleepCountPolicy,
|
||||
Map<String, Object> unknownFields) {
|
||||
Map<String, Object> unknownFields,
|
||||
Map<UUID, EyeProgress> eyes) {
|
||||
public PersistentStealthState {
|
||||
players = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(players, "players")));
|
||||
Objects.requireNonNull(sleepCountPolicy, "sleepCountPolicy");
|
||||
unknownFields = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(unknownFields, "unknownFields")));
|
||||
eyes = Map.copyOf(Objects.requireNonNull(eyes, "eyes"));
|
||||
}
|
||||
|
||||
public PersistentStealthState(Map<UUID, PlayerStealthState> players, SleepCountPolicy sleepCountPolicy,
|
||||
Map<String, Object> unknownFields) {
|
||||
this(players, sleepCountPolicy, unknownFields, Map.of());
|
||||
}
|
||||
|
||||
public PersistentStealthState(
|
||||
@@ -22,6 +29,14 @@ public record PersistentStealthState(
|
||||
this(players, SleepCountPolicy.EXCLUDE, unknownFields);
|
||||
}
|
||||
|
||||
public EyeProgress eyeProgress(UUID playerId) { return eyes.getOrDefault(playerId, EyeProgress.empty()); }
|
||||
|
||||
public PersistentStealthState withEyeProgress(UUID playerId, EyeProgress progress) {
|
||||
Map<UUID, EyeProgress> updated = new LinkedHashMap<>(eyes);
|
||||
updated.put(playerId, progress);
|
||||
return new PersistentStealthState(players, sleepCountPolicy, unknownFields, updated);
|
||||
}
|
||||
|
||||
public PlayerStealthState player(UUID playerId) {
|
||||
return players.getOrDefault(playerId, PlayerStealthState.empty(playerId));
|
||||
}
|
||||
@@ -29,10 +44,10 @@ public record PersistentStealthState(
|
||||
public PersistentStealthState withPlayer(PlayerStealthState player) {
|
||||
Map<UUID, PlayerStealthState> updated = new LinkedHashMap<>(players);
|
||||
updated.put(player.playerId(), player);
|
||||
return new PersistentStealthState(updated, sleepCountPolicy, unknownFields);
|
||||
return new PersistentStealthState(updated, sleepCountPolicy, unknownFields, eyes);
|
||||
}
|
||||
|
||||
public PersistentStealthState withSleepCountPolicy(SleepCountPolicy policy) {
|
||||
return new PersistentStealthState(players, policy, unknownFields);
|
||||
return new PersistentStealthState(players, policy, unknownFields, eyes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,20 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
/** Bukkit entry point for Spigot Stealth. */
|
||||
public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
||||
private CompletableFuture<Void> shutdown;
|
||||
private volatile long lifecycleEpoch;
|
||||
private StealthSettings settings;
|
||||
private QualifyingInvisibilityService progression;
|
||||
private StealthSessionService sessions;
|
||||
private IdentityPresentation identityPresentation;
|
||||
private ProtocolManager protocolManager;
|
||||
private EyeProgressionService eyeProgression;
|
||||
private EyePotionListener eyePotions;
|
||||
private boolean eyeRecipeRegistered;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
long epoch = ++lifecycleEpoch;
|
||||
saveDefaultConfig();
|
||||
try {
|
||||
settings = StealthSettings.from(getConfig().getValues(true));
|
||||
@@ -30,45 +36,73 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
}
|
||||
|
||||
Path stateFile = getDataFolder().toPath().resolve("state.yml");
|
||||
stateManagerFuture = StealthStateManager.load(new YamlStealthStateRepository(stateFile));
|
||||
stateManagerFuture = shutdownCompletion().thenCompose(ignored ->
|
||||
StealthStateManager.load(new YamlStealthStateRepository(stateFile)));
|
||||
stateManagerFuture.whenComplete((manager, failure) -> {
|
||||
if (failure != null) {
|
||||
getLogger().severe("Unable to load Spigot Stealth state: " + rootMessage(failure));
|
||||
getServer().getScheduler().runTask(this, () -> getServer().getPluginManager().disablePlugin(this));
|
||||
onServer(epoch, () -> getServer().getPluginManager().disablePlugin(this));
|
||||
return;
|
||||
}
|
||||
getServer().getScheduler().runTask(this, () -> finishInitialization(manager));
|
||||
if (!isEnabled() || epoch != lifecycleEpoch) {
|
||||
logSaveFailure(manager.closeAsync());
|
||||
return;
|
||||
}
|
||||
try {
|
||||
getServer().getScheduler().runTask(this, () -> {
|
||||
if (!isEnabled() || epoch != lifecycleEpoch) { logSaveFailure(manager.closeAsync()); }
|
||||
else { finishInitialization(manager, epoch); }
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
logSaveFailure(manager.closeAsync());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (protocolManager != null) {
|
||||
protocolManager.removePacketListeners(this);
|
||||
}
|
||||
if (identityPresentation != null && sessions != null) {
|
||||
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
||||
if (sessions.isConcealed(player.getUniqueId())) {
|
||||
identityPresentation.reveal(player);
|
||||
sessions.endConcealment(player.getUniqueId());
|
||||
++lifecycleEpoch;
|
||||
try {
|
||||
if (eyePotions != null) {
|
||||
eyePotions.checkpoint(getServer()::getPlayer);
|
||||
logSaveFailure(eyeProgression.stopAll());
|
||||
}
|
||||
if (progression != null) { logSaveFailure(progression.stopAll()); }
|
||||
if (protocolManager != null) { protocolManager.removePacketListeners(this); }
|
||||
if (identityPresentation != null && sessions != null) {
|
||||
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
|
||||
if (sessions.isConcealed(player.getUniqueId())) {
|
||||
try { identityPresentation.reveal(player); }
|
||||
finally { logSaveFailure(sessions.endConcealment(player.getUniqueId())); }
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (eyeRecipeRegistered) {
|
||||
getServer().removeRecipe(EyeCrafting.RECIPE_KEY);
|
||||
eyeRecipeRegistered = false;
|
||||
}
|
||||
} finally {
|
||||
if (stateManagerFuture != null) {
|
||||
shutdown = stateManagerFuture.thenCompose(StealthStateManager::closeAsync);
|
||||
logSaveFailure(shutdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (progression != null) {
|
||||
progression.stopAll().join();
|
||||
}
|
||||
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
|
||||
stateManagerFuture.join().close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable shutdown acknowledgement; never wait for it on the server thread. */
|
||||
CompletableFuture<Void> shutdownCompletion() {
|
||||
return shutdown == null ? CompletableFuture.completedFuture(null) : shutdown;
|
||||
}
|
||||
|
||||
public StealthSettings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
private void finishInitialization(StealthStateManager manager) {
|
||||
java.util.function.Consumer<Runnable> mainThread =
|
||||
runnable -> getServer().getScheduler().runTask(this, runnable);
|
||||
private void finishInitialization(StealthStateManager manager, long epoch) {
|
||||
java.util.function.Consumer<Runnable> mainThread = runnable -> onServer(epoch, runnable);
|
||||
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
|
||||
getServer()::getPlayer,
|
||||
mainThread,
|
||||
@@ -113,6 +147,36 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
settings.unlockThreshold());
|
||||
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
|
||||
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
|
||||
eyeProgression = new EyeProgressionService(manager, this::monotonicNanos, id -> mainThread.accept(() -> {
|
||||
var player = getServer().getPlayer(id);
|
||||
if (player != null) {
|
||||
player.discoverRecipe(EyeCrafting.RECIPE_KEY);
|
||||
player.sendMessage("You unlocked the Eye of True Seeing! Craft it with eight Netherite Blocks around an Eye of Ender.");
|
||||
}
|
||||
}));
|
||||
eyePotions = new EyePotionListener(eyeProgression,
|
||||
failure -> getLogger().severe("Unable to save Eye progression: " + rootMessage(failure)));
|
||||
var eyeItems = new EyeItems();
|
||||
var eyeCrafting = new EyeCrafting(eyeProgression, eyeItems);
|
||||
if (!getServer().addRecipe(eyeCrafting.recipe())) {
|
||||
getLogger().severe("Unable to register the Eye recipe; disabling Stealth rather than replacing another recipe.");
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
eyeRecipeRegistered = true;
|
||||
getServer().getPluginManager().registerEvents(eyePotions, this);
|
||||
getServer().getPluginManager().registerEvents(eyeCrafting, this);
|
||||
getServer().getPluginManager().registerEvents(new EyeEquipment(eyeProgression, eyeItems), this);
|
||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||
eyePotions.checkpoint(getServer()::getPlayer);
|
||||
for (var player : getServer().getOnlinePlayers()) {
|
||||
if (eyeProgression.isUnlocked(player.getUniqueId())) {
|
||||
if (!player.hasDiscoveredRecipe(EyeCrafting.RECIPE_KEY)) { player.discoverRecipe(EyeCrafting.RECIPE_KEY); }
|
||||
} else if (player.hasDiscoveredRecipe(EyeCrafting.RECIPE_KEY)) {
|
||||
player.undiscoverRecipe(EyeCrafting.RECIPE_KEY);
|
||||
}
|
||||
}
|
||||
}, 20L, 20L);
|
||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
||||
logSaveFailure(progression.checkpoint(playerId));
|
||||
@@ -127,6 +191,20 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
getLogger().info("Spigot Stealth enabled");
|
||||
}
|
||||
|
||||
/** Clock boundary for deterministic gameplay lifecycle verification. */
|
||||
long monotonicNanos() { return System.nanoTime(); }
|
||||
|
||||
private void onServer(long epoch, Runnable action) {
|
||||
if (!isEnabled() || epoch != lifecycleEpoch) { return; }
|
||||
try {
|
||||
getServer().getScheduler().runTask(this, () -> {
|
||||
if (isEnabled() && epoch == lifecycleEpoch) { action.run(); }
|
||||
});
|
||||
} catch (org.bukkit.plugin.IllegalPluginAccessException ignored) {
|
||||
// A durable unlock remains saved even if disable races its notification.
|
||||
}
|
||||
}
|
||||
|
||||
private void logSaveFailure(CompletableFuture<Void> save) {
|
||||
save.exceptionally(failure -> {
|
||||
getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure));
|
||||
|
||||
@@ -14,7 +14,9 @@ import java.util.function.UnaryOperator;
|
||||
public final class StealthStateManager implements AutoCloseable {
|
||||
private final StealthStateRepository repository;
|
||||
private final AtomicReference<PersistentStealthState> state;
|
||||
private final AtomicReference<PersistentStealthState> durableState;
|
||||
private final ExecutorService ioExecutor;
|
||||
private CompletableFuture<Void> closing;
|
||||
|
||||
public StealthStateManager(StealthStateRepository repository, PersistentStealthState initialState) {
|
||||
this(repository, initialState, newIoExecutor());
|
||||
@@ -26,6 +28,7 @@ public final class StealthStateManager implements AutoCloseable {
|
||||
ExecutorService ioExecutor) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository");
|
||||
this.state = new AtomicReference<>(Objects.requireNonNull(initialState, "initialState"));
|
||||
this.durableState = new AtomicReference<>(initialState);
|
||||
this.ioExecutor = ioExecutor;
|
||||
}
|
||||
|
||||
@@ -35,7 +38,7 @@ public final class StealthStateManager implements AutoCloseable {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
return new StealthStateManager(repository, repository.load(), executor);
|
||||
} catch (IOException exception) {
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
executor.shutdown();
|
||||
throw new CompletionException(exception);
|
||||
}
|
||||
@@ -46,36 +49,51 @@ public final class StealthStateManager implements AutoCloseable {
|
||||
return state.get();
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> update(UnaryOperator<PersistentStealthState> operation) {
|
||||
/** Last successfully written snapshot, for rewards that must not precede persistence acknowledgement. */
|
||||
public PersistentStealthState durableSnapshot() { return durableState.get(); }
|
||||
|
||||
public synchronized CompletableFuture<Void> update(UnaryOperator<PersistentStealthState> operation) {
|
||||
if (closing != null) { return CompletableFuture.failedFuture(new IllegalStateException("Stealth state is closing")); }
|
||||
PersistentStealthState updated = state.updateAndGet(operation);
|
||||
return persist(updated);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> save() {
|
||||
return persist(state.get());
|
||||
public synchronized CompletableFuture<Void> save() {
|
||||
return closing == null ? persist(state.get()) : closing;
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> persist(PersistentStealthState snapshot) {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
repository.save(snapshot);
|
||||
durableState.set(snapshot);
|
||||
} catch (IOException exception) {
|
||||
throw new CompletionException(exception);
|
||||
}
|
||||
}, ioExecutor);
|
||||
}
|
||||
|
||||
/** Queue the final snapshot and stop accepting mutations, without waiting on the caller's thread. */
|
||||
public synchronized CompletableFuture<Void> closeAsync() {
|
||||
if (closing == null) {
|
||||
closing = persist(state.get());
|
||||
ioExecutor.shutdown();
|
||||
}
|
||||
return closing;
|
||||
}
|
||||
|
||||
/** Blocking convenience for non-server callers such as tests; server lifecycle callers should use closeAsync. */
|
||||
@Override
|
||||
public void close() {
|
||||
save().join();
|
||||
ioExecutor.shutdown();
|
||||
try {
|
||||
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) {
|
||||
closeAsync().join();
|
||||
} finally {
|
||||
try {
|
||||
if (!ioExecutor.awaitTermination(10L, TimeUnit.SECONDS)) { ioExecutor.shutdownNow(); }
|
||||
} catch (InterruptedException exception) {
|
||||
ioExecutor.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
ioExecutor.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
/** Defensive YAML repository using atomic file replacement where available. */
|
||||
public final class YamlStealthStateRepository implements StealthStateRepository {
|
||||
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players");
|
||||
private static final Set<String> ROOT_FIELDS = Set.of("schema-version", "sleep-count-policy", "players", "eye-progress");
|
||||
private static final Set<String> EYE_FIELDS = Set.of("accumulated-millis", "unlocked");
|
||||
private static final Set<String> PLAYER_FIELDS = Set.of(
|
||||
"last-known-name", "accumulated-millis", "unlocked", "prepared-login", "concealed", "qualifying-since");
|
||||
private final Path stateFile;
|
||||
@@ -56,14 +57,15 @@ public final class YamlStealthStateRepository implements StealthStateRepository
|
||||
return new PersistentStealthState(
|
||||
players,
|
||||
SleepCountPolicy.fromPersisted(yaml.get("sleep-count-policy")),
|
||||
unknownRoot);
|
||||
unknownRoot,
|
||||
parseEyes(yaml.getConfigurationSection("eye-progress")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PersistentStealthState state) throws IOException {
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
state.unknownFields().forEach(yaml::set);
|
||||
yaml.set("schema-version", 1);
|
||||
yaml.set("schema-version", 2);
|
||||
yaml.set("sleep-count-policy", state.sleepCountPolicy().persistedValue());
|
||||
for (PlayerStealthState player : state.players().values()) {
|
||||
String base = "players." + player.playerId() + ".";
|
||||
@@ -75,6 +77,12 @@ public final class YamlStealthStateRepository implements StealthStateRepository
|
||||
yaml.set(base + "concealed", player.concealed());
|
||||
yaml.set(base + "qualifying-since", player.qualifyingSince() == null ? null : player.qualifyingSince().toString());
|
||||
}
|
||||
state.eyes().forEach((id, progress) -> {
|
||||
String base = "eye-progress." + id + ".";
|
||||
progress.unknownFields().forEach((key, value) -> yaml.set(base + key, value));
|
||||
yaml.set(base + "accumulated-millis", progress.accumulatedMillis());
|
||||
yaml.set(base + "unlocked", progress.unlocked());
|
||||
});
|
||||
Path parent = stateFile.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
@@ -88,6 +96,27 @@ public final class YamlStealthStateRepository implements StealthStateRepository
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<UUID, EyeProgress> parseEyes(ConfigurationSection section) {
|
||||
Map<UUID, EyeProgress> eyes = new LinkedHashMap<>();
|
||||
if (section == null) { return eyes; }
|
||||
for (String key : section.getKeys(false)) {
|
||||
try {
|
||||
UUID id = UUID.fromString(key);
|
||||
ConfigurationSection entry = section.getConfigurationSection(key);
|
||||
if (entry == null) { continue; }
|
||||
Object raw = entry.get("accumulated-millis");
|
||||
if (!(raw instanceof Long || raw instanceof Integer || raw instanceof Short || raw instanceof Byte)) {
|
||||
continue;
|
||||
}
|
||||
eyes.put(id, new EyeProgress(requireNonNegativeLong(entry, "accumulated-millis"),
|
||||
requireBoolean(entry, "unlocked"), unknownValues(entry, EYE_FIELDS)));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// A malformed Eye record cannot grant an unlock or erase independent Stealth records.
|
||||
}
|
||||
}
|
||||
return eyes;
|
||||
}
|
||||
|
||||
private static PlayerStealthState parsePlayer(UUID playerId, ConfigurationSection section) {
|
||||
long accumulated = requireNonNegativeLong(section, "accumulated-millis");
|
||||
boolean unlocked = requireBoolean(section, "unlocked");
|
||||
|
||||
Reference in New Issue
Block a user