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,215 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EyeCraftingTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void eightNetheriteBlocksAroundAnEnderEyeCanOnlyBeCraftedAfterThePersonalUnlock() throws Exception {
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
UUID id = UUID.randomUUID();
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, () -> 0L, ignored -> { });
|
||||
var items = new EyeItems();
|
||||
var crafting = new EyeCrafting(progression, items);
|
||||
var recipe = crafting.recipe();
|
||||
assertArrayEquals(new String[] {"NNN", "NEN", "NNN"}, recipe.getShape());
|
||||
assertEquals(Material.NETHERITE_BLOCK, recipe.getIngredientMap().get('N').getType());
|
||||
assertEquals(Material.ENDER_EYE, recipe.getIngredientMap().get('E').getType());
|
||||
assertTrue(items.isEye(recipe.getResult()));
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getRecipe()).thenReturn(recipe);
|
||||
when(inventory.getMatrix()).thenReturn(matrix());
|
||||
var result = new AtomicReference<>(recipe.getResult());
|
||||
when(inventory.getResult()).thenAnswer(ignored -> result.get());
|
||||
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
|
||||
var view = mock(InventoryView.class);
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
var event = new PrepareItemCraftEvent(inventory, view, false);
|
||||
crafting.onPrepare(event);
|
||||
assertNull(result.get(), "a locked player cannot obtain the recipe result");
|
||||
states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
|
||||
result.set(recipe.getResult()); // A fresh native recipe preparation after earning the unlock.
|
||||
crafting.onPrepare(event);
|
||||
assertTrue(items.isEye(result.get()));
|
||||
assertEquals(1, result.get().getAmount());
|
||||
verify(inventory, never()).setMatrix(any()); // Vanilla owns all ingredient consumption.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultClicksRecheckEligibilityIngredientsAndIdentityWithoutBypassingCancellation() throws Exception {
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
UUID id = UUID.randomUUID();
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, () -> 0L, ignored -> { });
|
||||
var items = new EyeItems();
|
||||
var crafting = new EyeCrafting(progression, items);
|
||||
var recipe = crafting.recipe();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getRecipe()).thenReturn(recipe);
|
||||
ItemStack[] ingredients = matrix();
|
||||
when(inventory.getMatrix()).thenReturn(ingredients);
|
||||
var result = new AtomicReference<>(recipe.getResult());
|
||||
when(inventory.getResult()).thenAnswer(ignored -> result.get());
|
||||
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
|
||||
var view = mock(InventoryView.class);
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
var locked = click(recipe, view);
|
||||
crafting.onCraft(locked);
|
||||
assertTrue(locked.isCancelled(), "a stale preview must not bypass the personal unlock on shift-click");
|
||||
assertNull(result.get());
|
||||
states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
|
||||
result.set(recipe.getResult());
|
||||
var allowed = click(recipe, view);
|
||||
crafting.onCraft(allowed);
|
||||
assertFalse(allowed.isCancelled());
|
||||
assertTrue(items.isEye(result.get()));
|
||||
ingredients[0] = new ItemStack(Material.DIRT);
|
||||
var invalidIngredients = click(recipe, view);
|
||||
crafting.onCraft(invalidIngredients);
|
||||
assertTrue(invalidIngredients.isCancelled());
|
||||
assertNull(result.get());
|
||||
ingredients[0] = new ItemStack(Material.NETHERITE_BLOCK);
|
||||
result.set(new ItemStack(Material.ENDER_EYE));
|
||||
var invalidResult = click(recipe, view);
|
||||
crafting.onCraft(invalidResult);
|
||||
assertTrue(invalidResult.isCancelled());
|
||||
result.set(recipe.getResult());
|
||||
result.get().setAmount(2);
|
||||
var inflatedResult = click(recipe, view);
|
||||
crafting.onCraft(inflatedResult);
|
||||
assertTrue(inflatedResult.isCancelled());
|
||||
result.set(recipe.getResult());
|
||||
var cancelled = click(recipe, view);
|
||||
cancelled.setCancelled(true);
|
||||
crafting.onCraft(cancelled);
|
||||
assertTrue(cancelled.isCancelled());
|
||||
var otherRecipe = new org.bukkit.inventory.ShapedRecipe(new org.bukkit.NamespacedKey("other", "eye"),
|
||||
new ItemStack(Material.ENDER_EYE)).shape("E").setIngredient('E', Material.ENDER_EYE);
|
||||
var unrelated = click(otherRecipe, view);
|
||||
crafting.onCraft(unrelated);
|
||||
assertFalse(unrelated.isCancelled());
|
||||
verify(inventory, never()).setMatrix(any());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingOrFailedUnlockWritesCannotExposeACraftableResult() throws Exception {
|
||||
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
var started = new java.util.concurrent.CountDownLatch(1);
|
||||
var release = new java.util.concurrent.CountDownLatch(1);
|
||||
var firstWrite = new java.util.concurrent.atomic.AtomicBoolean(true);
|
||||
StealthStateRepository repository = new StealthStateRepository() {
|
||||
@Override public PersistentStealthState load() throws java.io.IOException { return disk.load(); }
|
||||
@Override public void save(PersistentStealthState state) throws java.io.IOException {
|
||||
if (firstWrite.getAndSet(false)) {
|
||||
started.countDown();
|
||||
try {
|
||||
if (!release.await(3, TimeUnit.SECONDS)) { throw new java.io.IOException("Test write gate timed out"); }
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new java.io.IOException(exception);
|
||||
}
|
||||
throw new java.io.IOException("Injected write failure");
|
||||
}
|
||||
disk.save(state);
|
||||
}
|
||||
};
|
||||
UUID id = UUID.randomUUID();
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
try {
|
||||
var items = new EyeItems();
|
||||
var crafting = new EyeCrafting(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||
var recipe = crafting.recipe();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getRecipe()).thenReturn(recipe);
|
||||
when(inventory.getMatrix()).thenReturn(matrix());
|
||||
var result = new AtomicReference<>(recipe.getResult());
|
||||
when(inventory.getResult()).thenAnswer(ignored -> result.get());
|
||||
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
|
||||
var view = mock(InventoryView.class);
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
var event = new PrepareItemCraftEvent(inventory, view, false);
|
||||
var pending = states.update(state -> state.withEyeProgress(id, new EyeProgress(28_800_000, true)));
|
||||
assertTrue(started.await(3, TimeUnit.SECONDS));
|
||||
assertTrue(states.snapshot().eyeProgress(id).unlocked());
|
||||
crafting.onPrepare(event);
|
||||
assertNull(result.get(), "in-memory progress is not a durable crafting unlock");
|
||||
release.countDown();
|
||||
assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(3, TimeUnit.SECONDS));
|
||||
result.set(recipe.getResult());
|
||||
crafting.onPrepare(event);
|
||||
assertNull(result.get(), "failed writes must not grant the recipe");
|
||||
var denied = click(recipe, view);
|
||||
crafting.onCraft(denied);
|
||||
assertTrue(denied.isCancelled());
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
result.set(recipe.getResult());
|
||||
crafting.onPrepare(event);
|
||||
assertTrue(items.isEye(result.get()));
|
||||
var allowed = click(recipe, view);
|
||||
crafting.onCraft(allowed);
|
||||
assertFalse(allowed.isCancelled());
|
||||
} finally { release.countDown(); }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void automatedCraftersCannotBypassPersonalProgression() throws Exception {
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var items = new EyeItems();
|
||||
var crafting = new EyeCrafting(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||
var block = mock(org.bukkit.block.Block.class);
|
||||
var event = new org.bukkit.event.block.CrafterCraftEvent(block, crafting.recipe(), items.create());
|
||||
crafting.onCrafter(event);
|
||||
assertTrue(event.isCancelled(), "a block has no personal Eye unlock");
|
||||
var other = new org.bukkit.inventory.ShapedRecipe(new org.bukkit.NamespacedKey("other", "eye"),
|
||||
new ItemStack(Material.ENDER_EYE)).shape("E").setIngredient('E', Material.ENDER_EYE);
|
||||
var unrelated = new org.bukkit.event.block.CrafterCraftEvent(block, other, other.getResult());
|
||||
crafting.onCrafter(unrelated);
|
||||
assertFalse(unrelated.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
private static org.bukkit.event.inventory.CraftItemEvent click(org.bukkit.inventory.Recipe recipe, InventoryView view) {
|
||||
return new org.bukkit.event.inventory.CraftItemEvent(recipe, view,
|
||||
org.bukkit.event.inventory.InventoryType.SlotType.RESULT, 0,
|
||||
org.bukkit.event.inventory.ClickType.SHIFT_LEFT, org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY);
|
||||
}
|
||||
|
||||
private static ItemStack[] matrix() {
|
||||
var result = new ItemStack[9];
|
||||
for (int index = 0; index < result.length; index++) {
|
||||
result[index] = new ItemStack(index == 4 ? Material.ENDER_EYE : Material.NETHERITE_BLOCK);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.EnumMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EyeEquipmentTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void tradedEyesRequireTheHoldersOwnUnlockAndEquipWithoutThrowingOrReplacingAHelmet() throws Exception {
|
||||
UUID earner = UUID.randomUUID(), holder = UUID.randomUUID();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
states.update(state -> state.withEyeProgress(earner, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
|
||||
var items = new EyeItems();
|
||||
var equipment = new EyeEquipment(new EyeProgressionService(states, () -> 0L, ignored -> { }), items);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(holder);
|
||||
var inventory = mock(PlayerInventory.class);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
var helmet = new AtomicReference<ItemStack>();
|
||||
when(inventory.getHelmet()).thenAnswer(ignored -> helmet.get());
|
||||
doAnswer(call -> { helmet.set(call.getArgument(0)); return null; }).when(inventory).setHelmet(any());
|
||||
var hands = new EnumMap<EquipmentSlot, ItemStack>(EquipmentSlot.class);
|
||||
when(inventory.getItem(any(EquipmentSlot.class))).thenAnswer(call -> hands.get(call.getArgument(0)));
|
||||
doAnswer(call -> { hands.put(call.getArgument(0), call.getArgument(1)); return null; })
|
||||
.when(inventory).setItem(any(EquipmentSlot.class), nullable(ItemStack.class));
|
||||
ItemStack traded = items.create();
|
||||
hands.put(EquipmentSlot.HAND, traded);
|
||||
helmet.set(traded.clone());
|
||||
assertFalse(equipment.isEligibleWearer(player), "equipping somebody else's Eye must not bypass the personal unlock");
|
||||
helmet.set(null);
|
||||
var denied = use(player, traded, EquipmentSlot.HAND);
|
||||
equipment.onUse(denied);
|
||||
assertEquals(Event.Result.DENY, denied.useItemInHand(), "custom Eyes must never fall through to vanilla Ender Eye use");
|
||||
assertNull(helmet.get());
|
||||
assertSame(traded, hands.get(EquipmentSlot.HAND));
|
||||
states.update(state -> state.withEyeProgress(holder, new EyeProgress(28_800_000, true))).get(3, TimeUnit.SECONDS);
|
||||
var allowed = use(player, traded, EquipmentSlot.HAND);
|
||||
equipment.onUse(allowed);
|
||||
assertEquals(Event.Result.DENY, allowed.useItemInHand());
|
||||
assertNull(hands.get(EquipmentSlot.HAND));
|
||||
assertTrue(items.isEye(helmet.get()));
|
||||
assertEquals(1, helmet.get().getAmount());
|
||||
assertTrue(equipment.isEligibleWearer(player));
|
||||
var ordinaryHelmet = new ItemStack(Material.IRON_HELMET);
|
||||
helmet.set(ordinaryHelmet);
|
||||
hands.put(EquipmentSlot.OFF_HAND, items.create());
|
||||
equipment.onUse(use(player, hands.get(EquipmentSlot.OFF_HAND), EquipmentSlot.OFF_HAND));
|
||||
assertSame(ordinaryHelmet, helmet.get());
|
||||
assertTrue(items.isEye(hands.get(EquipmentSlot.OFF_HAND)));
|
||||
assertFalse(equipment.isEligibleWearer(player), "holding an Eye is not wearing it");
|
||||
helmet.set(null);
|
||||
equipment.onUse(use(player, hands.get(EquipmentSlot.OFF_HAND), EquipmentSlot.OFF_HAND));
|
||||
assertNull(hands.get(EquipmentSlot.OFF_HAND));
|
||||
assertTrue(equipment.isEligibleWearer(player));
|
||||
helmet.set(null);
|
||||
hands.put(EquipmentSlot.HAND, items.create());
|
||||
var cancelled = use(player, hands.get(EquipmentSlot.HAND), EquipmentSlot.HAND);
|
||||
cancelled.setUseItemInHand(Event.Result.DENY);
|
||||
equipment.onUse(cancelled);
|
||||
assertNull(helmet.get());
|
||||
assertTrue(items.isEye(hands.get(EquipmentSlot.HAND)));
|
||||
var ordinary = use(player, new ItemStack(Material.ENDER_EYE), EquipmentSlot.HAND);
|
||||
equipment.onUse(ordinary);
|
||||
assertEquals(Event.Result.DEFAULT, ordinary.useItemInHand());
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayerInteractEvent use(Player player, ItemStack item, EquipmentSlot hand) {
|
||||
return new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, item, null, BlockFace.SELF, hand);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class EyeItemsTest {
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void createdEyeIsAnAuthenticatedHelmetItemButPlainAndRenamedEyesAreNot() {
|
||||
var items = new EyeItems();
|
||||
ItemStack eye = items.create();
|
||||
assertEquals(Material.ENDER_EYE, eye.getType());
|
||||
assertEquals(Component.text("Eye of True Seeing"), eye.getItemMeta().displayName());
|
||||
assertTrue(items.isEye(eye));
|
||||
assertTrue(items.isEye(eye.clone()), "ordinary inventory transfer must retain item identity");
|
||||
assertEquals(EquipmentSlot.HEAD, eye.getItemMeta().getEquippable().getSlot());
|
||||
assertEquals(1, eye.getMaxStackSize());
|
||||
assertFalse(eye.getItemMeta().getEquippable().isDispensable());
|
||||
assertFalse(eye.getItemMeta().getEquippable().isEquipOnInteract(),
|
||||
"the eligibility-checked interaction handler, not native item use, equips the Eye");
|
||||
var ordinary = new ItemStack(Material.ENDER_EYE);
|
||||
assertFalse(items.isEye(ordinary));
|
||||
var renamed = ordinary.getItemMeta();
|
||||
renamed.displayName(eye.getItemMeta().displayName());
|
||||
ordinary.setItemMeta(renamed);
|
||||
assertFalse(items.isEye(ordinary), "anvil/display names cannot authenticate an Eye");
|
||||
var wrongMaterial = eye.clone();
|
||||
wrongMaterial.setType(Material.STONE);
|
||||
assertFalse(items.isEye(wrongMaterial));
|
||||
assertFalse(items.isEye(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Recipe;
|
||||
import org.bukkit.plugin.EventExecutor;
|
||||
import org.bukkit.plugin.PluginDescriptionFile;
|
||||
import org.bukkit.plugin.PluginManager;
|
||||
import org.bukkit.plugin.RegisteredListener;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.ScoreboardManager;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class EyePluginLifecycleTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"normal", "disabled", "reenabled", "presentation-failure"})
|
||||
@SuppressWarnings("try") // The scoped Bukkit boundary delegates non-server-metadata operations to native code.
|
||||
void startupAndLateCallbacksRespectSavedUnlocksAndDisable(String mode) throws Exception {
|
||||
UUID id = UUID.randomUUID(), learner = UUID.randomUUID();
|
||||
var disk = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
disk.save(new PersistentStealthState(Map.of(), Map.of()).withEyeProgress(id, new EyeProgress(28_800_000, true)));
|
||||
var plugin = mock(SpigotStealthPlugin.class, CALLS_REAL_METHODS);
|
||||
var clock = new java.util.concurrent.atomic.AtomicLong();
|
||||
doAnswer(ignored -> clock.get()).when(plugin).monotonicNanos();
|
||||
Server server = mock(org.bukkit.craftbukkit.CraftServer.class);
|
||||
var plugins = mock(PluginManager.class);
|
||||
BukkitScheduler scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
when(server.getPlayer(id)).thenReturn(player);
|
||||
doReturn(server).when(plugin).getServer();
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
doReturn(directory.toFile()).when(plugin).getDataFolder();
|
||||
doReturn(new YamlConfiguration()).when(plugin).getConfig();
|
||||
doReturn(Logger.getAnonymousLogger()).when(plugin).getLogger();
|
||||
doReturn(new PluginDescriptionFile("SpigotStealth", "test", SpigotStealthPlugin.class.getName()))
|
||||
.when(plugin).getDescription();
|
||||
doNothing().when(plugin).saveDefaultConfig();
|
||||
doReturn(mock(PluginCommand.class)).when(plugin).getCommand("stealth");
|
||||
doReturn(mock(PluginCommand.class)).when(plugin).getCommand("stealthadmin");
|
||||
when(server.getPluginManager()).thenReturn(plugins);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
ScoreboardManager boards = mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboardManager.class);
|
||||
when(server.getScoreboardManager()).thenReturn(boards);
|
||||
when(boards.getMainScoreboard()).thenReturn(mock(org.bukkit.craftbukkit.scoreboard.CraftScoreboard.class));
|
||||
var main = new LinkedBlockingQueue<Runnable>();
|
||||
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
|
||||
main.add(call.getArgument(1)); return mock(BukkitTask.class);
|
||||
});
|
||||
var timers = new ArrayList<Runnable>();
|
||||
doAnswer(call -> {
|
||||
Consumer<BukkitTask> action = call.getArgument(1);
|
||||
timers.add(() -> action.accept(mock(BukkitTask.class)));
|
||||
return null;
|
||||
}).when(scheduler).runTaskTimer(eq(plugin), org.mockito.ArgumentMatchers.<Consumer<BukkitTask>>any(), anyLong(), anyLong());
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), anyLong(), anyLong())).thenAnswer(call -> {
|
||||
timers.add(call.getArgument(1)); return mock(BukkitTask.class);
|
||||
});
|
||||
var recipe = new AtomicReference<Recipe>();
|
||||
when(server.addRecipe(any(Recipe.class))).thenAnswer(call -> { recipe.set(call.getArgument(0)); return true; });
|
||||
var listeners = new ArrayList<Listener>();
|
||||
doAnswer(call -> { listeners.add(call.getArgument(0)); return null; }).when(plugins).registerEvents(any(), eq(plugin));
|
||||
try (var platform = mockStatic(org.bukkit.Bukkit.class, call -> switch (call.getMethod().getName()) {
|
||||
case "getServer" -> server;
|
||||
case "getVersion" -> "Purpur 2618 (MC: 26.2)";
|
||||
case "getMinecraftVersion" -> "26.2";
|
||||
case "getBukkitVersion" -> "26.2-R0.1-SNAPSHOT";
|
||||
default -> call.callRealMethod();
|
||||
}); var protocol = mockStatic(ProtocolLibrary.class)) {
|
||||
var protocolManager = mock(ProtocolManager.class);
|
||||
protocol.when(ProtocolLibrary::getProtocolManager).thenReturn(protocolManager);
|
||||
try {
|
||||
plugin.onEnable();
|
||||
assertNull(recipe.get(), "Bukkit registration must wait for durable state initialization and main-thread dispatch");
|
||||
Runnable initialization = main.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(initialization);
|
||||
if (mode.equals("disabled") || mode.equals("reenabled")) {
|
||||
doReturn(false).when(plugin).isEnabled();
|
||||
plugin.onDisable();
|
||||
if (mode.equals("reenabled")) {
|
||||
doReturn(true).when(plugin).isEnabled();
|
||||
plugin.onEnable();
|
||||
Runnable currentInitialization = main.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(currentInitialization);
|
||||
currentInitialization.run();
|
||||
int registeredCount = listeners.size();
|
||||
assertNotNull(recipe.get());
|
||||
initialization.run();
|
||||
assertEquals(registeredCount, listeners.size(), "an old lifecycle callback must not touch a re-enabled plugin");
|
||||
verify(server, times(1)).addRecipe(any(Recipe.class));
|
||||
return;
|
||||
}
|
||||
initialization.run();
|
||||
assertTrue(listeners.isEmpty(), "a queued initialization must not register listeners after disable");
|
||||
assertNull(recipe.get());
|
||||
verify(server, never()).removeRecipe(any());
|
||||
return;
|
||||
}
|
||||
initialization.run();
|
||||
assertNotNull(recipe.get(), "plugin startup must register the Eye recipe");
|
||||
assertTrue(new EyeItems().isEye(recipe.get().getResult()));
|
||||
assertTrue(listeners.stream().anyMatch(EyePotionListener.class::isInstance));
|
||||
assertTrue(listeners.stream().anyMatch(EyeEquipment.class::isInstance));
|
||||
var inventory = mock(CraftingInventory.class);
|
||||
when(inventory.getRecipe()).thenAnswer(ignored -> recipe.get());
|
||||
var result = new AtomicReference<>(recipe.get().getResult());
|
||||
when(inventory.getResult()).thenAnswer(ignored -> result.get());
|
||||
doAnswer(call -> { result.set(call.getArgument(0)); return null; }).when(inventory).setResult(any());
|
||||
var view = mock(InventoryView.class);
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
when(view.getTopInventory()).thenReturn(inventory);
|
||||
var prepare = new PrepareItemCraftEvent(inventory, view, false);
|
||||
dispatch(plugin, listeners, prepare);
|
||||
assertTrue(new EyeItems().isEye(result.get()), "the restored personal unlock must permit crafting");
|
||||
when(player.getUniqueId()).thenReturn(learner);
|
||||
when(server.getPlayer(learner)).thenReturn(player);
|
||||
dispatch(plugin, listeners, prepare);
|
||||
assertNull(result.get(), "registered handlers must deny an unearned player's result");
|
||||
var effect = new org.bukkit.potion.PotionEffect(org.bukkit.potion.PotionEffectType.NIGHT_VISION, 9600, 0);
|
||||
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, null, effect, player,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_DRINK,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.set(java.time.Duration.ofHours(8).toNanos());
|
||||
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, effect, null, null,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.EXPIRATION,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
Runnable unlocked = main.poll(3, TimeUnit.SECONDS);
|
||||
assertNotNull(unlocked, "the registered potion listener must persist the unlock and schedule its notification");
|
||||
assertTrue(disk.load().eyeProgress(learner).unlocked());
|
||||
unlocked.run();
|
||||
result.set(recipe.get().getResult());
|
||||
dispatch(plugin, listeners, prepare);
|
||||
assertTrue(new EyeItems().isEye(result.get()), "the registered crafting listener must see the newly saved unlock");
|
||||
clearInvocations(player);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
timers.forEach(Runnable::run);
|
||||
verify(player).discoverRecipe(EyeCrafting.RECIPE_KEY);
|
||||
// Disable must checkpoint and drain an in-flight qualifying interval, not just remove the recipe.
|
||||
when(player.getUniqueId()).thenReturn(learner);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getPotionEffect(org.bukkit.potion.PotionEffectType.NIGHT_VISION)).thenReturn(effect);
|
||||
dispatch(plugin, listeners, new org.bukkit.event.entity.EntityPotionEffectEvent(player, null, effect, player,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Cause.POTION_DRINK,
|
||||
org.bukkit.event.entity.EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.addAndGet(1_234_000_000L);
|
||||
} finally {
|
||||
if (mode.equals("presentation-failure")) {
|
||||
doThrow(new IllegalStateException("Injected presentation cleanup failure"))
|
||||
.when(protocolManager).removePacketListeners(plugin);
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, plugin::onDisable);
|
||||
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(28_801_234, disk.load().eyeProgress(learner).accumulatedMillis(),
|
||||
"presentation cleanup failure must not prevent Eye progress from draining");
|
||||
} finally {
|
||||
doNothing().when(protocolManager).removePacketListeners(plugin);
|
||||
plugin.onDisable();
|
||||
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
|
||||
}
|
||||
} else {
|
||||
plugin.onDisable();
|
||||
plugin.shutdownCompletion().get(3, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
verify(server).removeRecipe(EyeCrafting.RECIPE_KEY);
|
||||
assertEquals(28_801_234, disk.load().eyeProgress(learner).accumulatedMillis());
|
||||
}
|
||||
|
||||
private static void dispatch(SpigotStealthPlugin plugin, List<Listener> listeners, Event event) throws Exception {
|
||||
var registered = new ArrayList<RegisteredListener>();
|
||||
for (var listener : listeners) {
|
||||
for (var method : listener.getClass().getMethods()) {
|
||||
var handler = method.getAnnotation(EventHandler.class);
|
||||
if (handler == null || method.getParameterCount() != 1 || !method.getParameterTypes()[0].isInstance(event)) { continue; }
|
||||
registered.add(new RegisteredListener(listener,
|
||||
EventExecutor.create(method, method.getParameterTypes()[0].asSubclass(Event.class)),
|
||||
handler.priority(), plugin, handler.ignoreCancelled()));
|
||||
}
|
||||
}
|
||||
registered.sort(java.util.Comparator.comparing(RegisteredListener::getPriority));
|
||||
for (var handler : registered) { handler.callEvent(event); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** Real Purpur effect/event objects, real progression/persistence, only player and clock are external doubles. */
|
||||
class EyePotionProgressionTest {
|
||||
@TempDir Path directory;
|
||||
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
|
||||
|
||||
@Test
|
||||
void aTransitionBeforeTheNextObservationCannotCreditAnUnattributedRestoredEffect() throws Exception {
|
||||
for (String transition : java.util.List.of("quit", "remove", "refresh")) {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve(transition + ".yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
|
||||
var restored = new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(strong);
|
||||
clock.set(Duration.ofSeconds(1).toNanos());
|
||||
listener.checkpoint(ignored -> player);
|
||||
clock.set(Duration.ofSeconds(3).toNanos());
|
||||
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(restored);
|
||||
switch (transition) {
|
||||
case "quit" -> listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(
|
||||
player, net.kyori.adventure.text.Component.empty()));
|
||||
case "remove" -> listener.onPotionEffect(new EntityPotionEffectEvent(player, restored, null, null,
|
||||
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
case "refresh" -> listener.onPotionEffect(new EntityPotionEffectEvent(player, restored,
|
||||
new PotionEffect(PotionEffectType.NIGHT_VISION, 800, 0), player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
|
||||
default -> throw new AssertionError(transition);
|
||||
}
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(), transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void periodicObservationStopsAtUnattributedHiddenEffectRestorationOrMissingPlayer() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 1);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(
|
||||
new PotionEffect(PotionEffectType.NIGHT_VISION, 80, 1));
|
||||
clock.set(Duration.ofSeconds(1).toNanos());
|
||||
listener.checkpoint(ignored -> player);
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
|
||||
// Native hidden-effect promotion need not emit a new potion-source event.
|
||||
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(
|
||||
new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0));
|
||||
clock.set(Duration.ofSeconds(2).toNanos());
|
||||
listener.checkpoint(ignored -> player);
|
||||
clock.set(Duration.ofHours(12).toNanos());
|
||||
listener.checkpoint(ignored -> player);
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(),
|
||||
"unattributed restored effects must not inherit the drink timer");
|
||||
assertTrue(progression.activePlayerIds().isEmpty());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.addAndGet(Duration.ofDays(1).toNanos());
|
||||
listener.checkpoint(ignored -> null);
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
|
||||
assertTrue(progression.activePlayerIds().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void disconnectClosesTheIntervalAndRestartDoesNotCountTheOfflineGap() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.set(Duration.ofSeconds(1).toNanos());
|
||||
when(player.getPotionEffect(PotionEffectType.NIGHT_VISION)).thenReturn(effect);
|
||||
listener.onQuit(new org.bukkit.event.player.PlayerQuitEvent(player, net.kyori.adventure.text.Component.empty()));
|
||||
clock.addAndGet(Duration.ofDays(7).toNanos());
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis(),
|
||||
"disconnect must close the qualifying interval before offline time passes");
|
||||
assertFalse(progression.isUnlocked(id));
|
||||
}
|
||||
try (var restarted = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(restarted, clock::get, ignored -> { });
|
||||
assertEquals(1000, restarted.snapshot().eyeProgress(id).accumulatedMillis());
|
||||
assertTrue(progression.activePlayerIds().isEmpty());
|
||||
assertFalse(progression.isUnlocked(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyNonDrinkSourceIsExcludedAndAnEffectiveExternalReplacementStopsCredit() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
|
||||
for (var cause : EntityPotionEffectEvent.Cause.values()) {
|
||||
if (cause == EntityPotionEffectEvent.Cause.POTION_DRINK) { continue; }
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, null,
|
||||
cause, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.addAndGet(Duration.ofSeconds(1).toNanos());
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(), cause.toString());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, effect, null, null,
|
||||
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
}
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, effect, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.addAndGet(Duration.ofSeconds(1).toNanos());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, effect,
|
||||
new PotionEffect(PotionEffectType.NIGHT_VISION, 400, 0), null,
|
||||
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, true));
|
||||
clock.addAndGet(Duration.ofHours(12).toNanos());
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(1000, repository.load().eyeProgress(id).accumulatedMillis());
|
||||
assertFalse(progression.isUnlocked(id));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelledAndUnrelatedEffectEventsCannotStartOrInterruptCredit() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var effect = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 0);
|
||||
var cancelled = new EntityPotionEffectEvent(player, null, effect, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false);
|
||||
cancelled.setCancelled(true);
|
||||
listener.onPotionEffect(cancelled);
|
||||
clock.set(Duration.ofSeconds(1).toNanos());
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis());
|
||||
cancelled.setCancelled(false);
|
||||
listener.onPotionEffect(cancelled);
|
||||
clock.set(Duration.ofSeconds(2).toNanos());
|
||||
var cancelledRemoval = new EntityPotionEffectEvent(player, effect, null, null,
|
||||
EntityPotionEffectEvent.Cause.MILK, EntityPotionEffectEvent.Action.CLEARED, false);
|
||||
cancelledRemoval.setCancelled(true);
|
||||
listener.onPotionEffect(cancelledRemoval);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null,
|
||||
new PotionEffect(PotionEffectType.INVISIBILITY, 200, 0), null,
|
||||
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.set(Duration.ofSeconds(3).toNanos());
|
||||
progression.stop(id).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(2000, repository.load().eyeProgress(id).accumulatedMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cosmeticOnlyOverridesDoNotReassignTheActiveEffectsSource() throws Exception {
|
||||
// Purpur reports override=true even when only particle/icon flags change.
|
||||
var nativeActive = new net.minecraft.world.effect.MobEffectInstance(
|
||||
net.minecraft.world.effect.MobEffects.NIGHT_VISION, 200, 1);
|
||||
assertTrue(nativeActive.update(new net.minecraft.world.effect.MobEffectInstance(
|
||||
net.minecraft.world.effect.MobEffects.NIGHT_VISION, 100, 0, false, false, false)));
|
||||
assertEquals(1, nativeActive.getAmplifier());
|
||||
assertEquals(200, nativeActive.getDuration());
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var weak = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0, false, false, false);
|
||||
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 1);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
|
||||
clock.set(Duration.ofSeconds(1).toNanos());
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(),
|
||||
"a cosmetic drink change must not claim the stronger existing effect");
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.set(Duration.ofSeconds(2).toNanos());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, null,
|
||||
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, true));
|
||||
clock.set(Duration.ofSeconds(3).toNanos());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, null, null,
|
||||
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(2000, states.snapshot().eyeProgress(id).accumulatedMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectedReplacementsNeitherStartNorStopQualifyingTime() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var weak = new PotionEffect(PotionEffectType.NIGHT_VISION, 100, 0);
|
||||
var strong = new PotionEffect(PotionEffectType.NIGHT_VISION, 200, 1);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, false));
|
||||
clock.set(Duration.ofSeconds(1).toNanos());
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
assertEquals(0, states.snapshot().eyeProgress(id).accumulatedMillis(),
|
||||
"a rejected drink must not claim an existing non-drink effect");
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, strong, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, false));
|
||||
clock.set(Duration.ofSeconds(2).toNanos());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, weak, null,
|
||||
EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED, false));
|
||||
clock.set(Duration.ofSeconds(3).toNanos());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, strong, null, null,
|
||||
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertEquals(2000, states.snapshot().eyeProgress(id).accumulatedMillis(),
|
||||
"a rejected plugin replacement must not interrupt the effective drunk potion");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void effectiveDirectDrinkAccumulatesAcrossRefreshAndDurablyUnlocksAtEightHours() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
var player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
var clock = new AtomicLong();
|
||||
var repository = new YamlStealthStateRepository(directory.resolve("state.yml"));
|
||||
try (var states = new StealthStateManager(repository, repository.load())) {
|
||||
var progression = new EyeProgressionService(states, clock::get, ignored -> { });
|
||||
var listener = new EyePotionListener(progression, failure -> fail(failure));
|
||||
var nightVision = new PotionEffect(PotionEffectType.NIGHT_VISION, 9600, 0);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, null, nightVision, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED, true));
|
||||
for (int refresh = 1; refresh < 60; refresh++) {
|
||||
clock.set(Duration.ofSeconds(refresh * 480L).toNanos());
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, new PotionEffect(PotionEffectType.NIGHT_VISION, 1, 0), nightVision, player,
|
||||
EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.CHANGED, true));
|
||||
}
|
||||
clock.set(Duration.ofHours(8).toNanos() - 1_000_000);
|
||||
progression.checkpoint(id).get(3, TimeUnit.SECONDS);
|
||||
assertFalse(progression.isUnlocked(id));
|
||||
clock.addAndGet(1_000_000);
|
||||
listener.onPotionEffect(new EntityPotionEffectEvent(player, nightVision, null, null,
|
||||
EntityPotionEffectEvent.Cause.EXPIRATION, EntityPotionEffectEvent.Action.REMOVED, false));
|
||||
states.save().get(3, TimeUnit.SECONDS);
|
||||
assertTrue(progression.isUnlocked(id), "eight hours of effective drink events must unlock the Eye");
|
||||
assertEquals(28_800_000, repository.load().eyeProgress(id).accumulatedMillis());
|
||||
assertTrue(progression.activePlayerIds().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.LayeredRegistryAccess;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.RegistryLayer;
|
||||
import net.minecraft.server.ReloadableServerResources;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.repository.ServerPacksSource;
|
||||
import net.minecraft.server.packs.resources.MultiPackResourceManager;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.util.Util;
|
||||
import net.minecraft.world.flag.FeatureFlags;
|
||||
import net.minecraft.world.level.DataPackConfig;
|
||||
import net.minecraft.world.level.WorldDataConfiguration;
|
||||
import org.bukkit.craftbukkit.CraftRegistry;
|
||||
|
||||
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
|
||||
final class NativeRuntime {
|
||||
private NativeRuntime() {}
|
||||
private static boolean ready;
|
||||
|
||||
static synchronized void bootstrap() throws Exception {
|
||||
if (ready) { return; }
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
var flags = FeatureFlags.VANILLA_SET;
|
||||
var packs = ServerPacksSource.createVanillaTrustedRepository();
|
||||
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
|
||||
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
|
||||
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
|
||||
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
|
||||
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
|
||||
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
|
||||
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
|
||||
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
|
||||
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
|
||||
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
|
||||
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
|
||||
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
|
||||
Class.forName(org.bukkit.Registry.class.getName());
|
||||
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
|
||||
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
|
||||
Util.backgroundExecutor(), Runnable::run).join();
|
||||
datapack.updateComponentsAndStaticRegistryTags();
|
||||
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
|
||||
}
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user