diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12bdefc..9703b45 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,11 +14,11 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: distribution: 'adopt' - java-version: '17' + java-version: '21' - name: Cache SonarCloud packages uses: actions/cache@v3 with: diff --git a/pom.xml b/pom.xml index 0d721cd..8f54038 100644 --- a/pom.xml +++ b/pom.xml @@ -50,11 +50,12 @@ UTF-8 UTF-8 - 17 + 21 2.0.9 - 1.21.3-R0.1-SNAPSHOT + 1.21.8-R0.1-SNAPSHOT + 1.21.8-R0.1-SNAPSHOT 3.0.0-SNAPSHOT 2.7.0-SNAPSHOT @@ -62,7 +63,7 @@ -LOCAL - 1.16.0 + 1.17.0 BentoBoxWorld_Warps bentobox-world @@ -112,6 +113,10 @@ + + papermc + https://repo.papermc.io/repository/maven-public/ + spigot-repo https://hub.spigotmc.org/nexus/content/repositories/snapshots @@ -131,6 +136,13 @@ + + + io.papermc.paper + paper-api + ${paper.version} + provided + org.spigotmc @@ -223,10 +235,11 @@ ${java.version} - + org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.5.2 + ${argLine} @@ -234,29 +247,24 @@ --add-opens java.base/java.math=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED - --add-opens - java.base/java.util.stream=ALL-UNNAMED + --add-opens java.base/java.util.stream=ALL-UNNAMED --add-opens java.base/java.text=ALL-UNNAMED - --add-opens - java.base/java.util.regex=ALL-UNNAMED - --add-opens - java.base/java.nio.channels.spi=ALL-UNNAMED + --add-opens java.base/java.util.regex=ALL-UNNAMED + --add-opens java.base/java.nio.channels.spi=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED --add-opens java.base/java.net=ALL-UNNAMED - --add-opens - java.base/java.util.concurrent=ALL-UNNAMED + --add-opens java.base/java.util.concurrent=ALL-UNNAMED --add-opens java.base/sun.nio.fs=ALL-UNNAMED --add-opens java.base/sun.nio.cs=ALL-UNNAMED --add-opens java.base/java.nio.file=ALL-UNNAMED - --add-opens - java.base/java.nio.charset=ALL-UNNAMED - --add-opens - java.base/java.lang.reflect=ALL-UNNAMED - --add-opens - java.logging/java.util.logging=ALL-UNNAMED + --add-opens java.base/java.nio.charset=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + --add-opens java.logging/java.util.logging=ALL-UNNAMED --add-opens java.base/java.lang.ref=ALL-UNNAMED --add-opens java.base/java.util.jar=ALL-UNNAMED --add-opens java.base/java.util.zip=ALL-UNNAMED + --add-opens=java.base/java.security=ALL-UNNAMED + --add-opens java.base/jdk.internal.misc=ALL-UNNAMED diff --git a/src/main/java/world/bentobox/warps/listeners/WarpSignsListener.java b/src/main/java/world/bentobox/warps/listeners/WarpSignsListener.java index 3d981f7..6ee8da2 100644 --- a/src/main/java/world/bentobox/warps/listeners/WarpSignsListener.java +++ b/src/main/java/world/bentobox/warps/listeners/WarpSignsListener.java @@ -1,6 +1,11 @@ package world.bentobox.warps.listeners; -import java.util.*; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; import java.util.stream.Collectors; import org.bukkit.Bukkit; @@ -10,6 +15,8 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.Sign; +import org.bukkit.block.sign.Side; +import org.bukkit.block.sign.SignSide; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -18,7 +25,6 @@ import org.bukkit.event.block.SignChangeEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.scheduler.BukkitRunnable; -import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.events.addon.AddonEvent; @@ -28,9 +34,9 @@ import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; -import world.bentobox.warps.objects.PlayerWarp; import world.bentobox.warps.Warp; import world.bentobox.warps.event.WarpRemoveEvent; +import world.bentobox.warps.objects.PlayerWarp; /** * Handles warping. Players can add one sign @@ -106,7 +112,6 @@ public void onSignBreak(BlockBreakEvent e) { Block b = e.getBlock(); boolean inWorld = addon.getPlugin().getIWM().inWorld(b.getWorld()); // Signs only - // FIXME: When we drop support for 1.13, switch to Tag.SIGNS if (!b.getType().name().contains("SIGN") || (inWorld && !addon.inRegisteredWorld(b.getWorld())) || (!inWorld && !addon.getSettings().isAllowInOtherWorlds()) @@ -125,6 +130,13 @@ public void onSignBreak(BlockBreakEvent e) { } } + /** + * Check if this block is a registered warp sign owned by player so that it can be acted on + * @param player - player trying to do something to the sign + * @param b - sign block + * @param inWorld - true if this is a BentoBox game world + * @return true if this player is op, has mod bypass permission, or is the sign owner + */ private boolean isPlayersSign(Player player, Block b, boolean inWorld) { // Welcome sign detected - check to see if it is this player's sign Map list = addon.getWarpSignsManager().getWarpMap(b.getWorld()); @@ -133,10 +145,19 @@ private boolean isPlayersSign(Player player, Block b, boolean inWorld) { || player.isOp() || player.hasPermission(reqPerm)); } + /** + * Checks if this block is a warp sign. Requires it to have the correct title and be registered as a warp sign + * @param b warp sign block + * @return true if it is a valid warp sign + */ private boolean isWarpSign(Block b) { - Sign s = (Sign) b.getState(); - return s.getLine(0).equalsIgnoreCase(ChatColor.GREEN + addon.getSettings().getWelcomeLine()) - && addon.getWarpSignsManager().getWarpMap(b.getWorld()).values().stream().anyMatch(playerWarp -> playerWarp.getLocation().equals(s.getLocation())); + if (b.getState() instanceof Sign s) { + SignSide side = s.getSide(Side.FRONT); + return side.getLine(0).equalsIgnoreCase(ChatColor.GREEN + addon.getSettings().getWelcomeLine()) + && addon.getWarpSignsManager().getWarpMap(b.getWorld()).values().stream() + .anyMatch(playerWarp -> playerWarp.getLocation().equals(s.getLocation())); + } + return false; } /** @@ -146,24 +167,27 @@ private boolean isWarpSign(Block b) { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onSignWarpCreate(SignChangeEvent e) { + User user = Objects.requireNonNull(User.getInstance(e.getPlayer())); Block b = e.getBlock(); + Location loc = b.getLocation(); boolean inWorld = addon.getPlugin().getIWM().inWorld(b.getWorld()); if ((inWorld && !addon.inRegisteredWorld(b.getWorld())) || (!inWorld && !addon.getSettings().isAllowInOtherWorlds()) ) { return; } String title = e.getLine(0); - User user = Objects.requireNonNull(User.getInstance(e.getPlayer())); + if (title != null && !title.equalsIgnoreCase(addon.getSettings().getWelcomeLine()) && addon.getWarpSignsManager().isWarpAt(loc)) { + UUID owner = addon.getWarpSignsManager().getWarpOwnerUUID(loc).orElse(null); + addon.getWarpSignsManager().removeWarp(loc); + Bukkit.getPluginManager().callEvent(new WarpRemoveEvent(loc, user.getUniqueId(), owner)); + return; + } // Check if someone is changing their own sign if (title != null && title.equalsIgnoreCase(addon.getSettings().getWelcomeLine())) { // Welcome sign detected - check permissions if (noPerms(user, b.getWorld(), inWorld)) { return; } - // TODO: These checks are useless if the sign is placed outside a BSB world. - // This will mean level and rank requirements are nil in the case of allow-in-other-worlds: true. - // I'm not sure if there is a better way around this without adding new API checking for primary - // or last island accessed with relevant permissions. - // ignored. + if (inWorld && noLevelOrIsland(user, b.getWorld())) { e.setLine(0, ChatColor.RED + addon.getSettings().getWelcomeLine()); return; @@ -182,17 +206,25 @@ public void onSignWarpCreate(SignChangeEvent e) { // so, // deactivate it Block oldSignBlock = oldSignLoc.getBlock(); - // FIXME: When we drop support for 1.13, switch to Tag.SIGNS - if (oldSignBlock.getType().name().contains("SIGN")) { + if (oldSignBlock.getState() instanceof Sign oldSign) { // The block is still a sign - Sign oldSign = (Sign) oldSignBlock.getState(); - if (oldSign.getLine(0).equalsIgnoreCase(ChatColor.GREEN + addon.getSettings().getWelcomeLine())) { - oldSign.setLine(0, ChatColor.RED + addon.getSettings().getWelcomeLine()); + SignSide front = oldSign.getSide(Side.FRONT); + SignSide back = oldSign.getSide(Side.BACK); + String welcome = ChatColor.GREEN + addon.getSettings().getWelcomeLine(); + String disabled = ChatColor.RED + addon.getSettings().getWelcomeLine(); + boolean remove = false; + if (front.getLine(0).equalsIgnoreCase(welcome)) { + front.setLine(0, disabled); + remove = true; + } else if (back.getLine(0).equalsIgnoreCase(welcome)) { + back.setLine(0, disabled); + remove = true; + } + if (remove) { oldSign.update(true, false); user.sendMessage(WARPS_DEACTIVATE); - addon.getWarpSignsManager().removeWarp(oldSignBlock.getWorld(), user.getUniqueId()); - @Nullable UUID owner = addon.getWarpSignsManager().getWarpOwnerUUID(oldSignLoc).orElse(null); + addon.getWarpSignsManager().removeWarp(oldSignBlock.getWorld(), user.getUniqueId()); Bukkit.getPluginManager().callEvent(new WarpRemoveEvent(oldSign.getLocation(), user.getUniqueId(), owner)); } } @@ -200,7 +232,6 @@ public void onSignWarpCreate(SignChangeEvent e) { } addSign(e, user, b); } - } private boolean hasCorrectIslandRank(Block b, User user) { diff --git a/src/main/java/world/bentobox/warps/managers/WarpSignsManager.java b/src/main/java/world/bentobox/warps/managers/WarpSignsManager.java index 18a7773..44b94d4 100644 --- a/src/main/java/world/bentobox/warps/managers/WarpSignsManager.java +++ b/src/main/java/world/bentobox/warps/managers/WarpSignsManager.java @@ -152,6 +152,15 @@ public Optional getWarpOwnerUUID(Location location) { return getWarpMap(location.getWorld()).entrySet().stream().filter(en -> en.getValue().getLocation().equals(location)) .findFirst().map(Map.Entry::getKey); } + + /** + * Check if there is a warp sign at this location + * @param location location to check + * @return true if there is a warp sign at this location + */ + public boolean isWarpAt(Location location) { + return getWarpMap(location.getWorld()).entrySet().stream().map(en -> en.getValue().getLocation().equals(location)).findFirst().isPresent(); + } /** * Get sorted list of warps with most recent players listed first diff --git a/src/test/java/world/bentobox/warps/listeners/WarpSignsListenerTest.java b/src/test/java/world/bentobox/warps/listeners/WarpSignsListenerTest.java index 2840671..ec09151 100644 --- a/src/test/java/world/bentobox/warps/listeners/WarpSignsListenerTest.java +++ b/src/test/java/world/bentobox/warps/listeners/WarpSignsListenerTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -32,12 +33,14 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.Sign; +import org.bukkit.block.sign.SignSide; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Player; import org.bukkit.entity.Player.Spigot; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.SignChangeEvent; import org.bukkit.plugin.PluginManager; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -49,6 +52,7 @@ import org.powermock.core.classloader.annotations.PrepareForTest; import org.powermock.modules.junit4.PowerMockRunner; +import io.papermc.paper.ServerBuildInfo; import net.md_5.bungee.api.chat.TextComponent; import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.events.flags.FlagProtectionChangeEvent; @@ -63,6 +67,7 @@ import world.bentobox.warps.Warp; import world.bentobox.warps.config.Settings; import world.bentobox.warps.managers.WarpSignsManager; +import world.bentobox.warps.mocks.ServerMocks; import world.bentobox.warps.objects.PlayerWarp; /** @@ -70,7 +75,7 @@ * */ @RunWith(PowerMockRunner.class) -@PrepareForTest({Bukkit.class, Util.class, NamespacedKey.class, Tag.class}) +@PrepareForTest({Bukkit.class, Util.class, NamespacedKey.class, Tag.class, ServerBuildInfo.class}) public class WarpSignsListenerTest { @Mock @@ -81,8 +86,11 @@ public class WarpSignsListenerTest { private Player player; @Mock private World world; + @Mock private Sign s; @Mock + private SignSide signSide; + @Mock private WarpSignsManager wsm; private PluginManager pm; private String[] lines; @@ -101,20 +109,18 @@ public class WarpSignsListenerTest { @Before public void setUp() { + ServerMocks.newServer(); + // Bukkit PowerMockito.mockStatic(Bukkit.class); pm = mock(PluginManager.class); when(Bukkit.getPluginManager()).thenReturn(pm); - Server server = mock(Server.class); - when(server.getVersion()).thenReturn("1.14"); - when(Bukkit.getServer()).thenReturn(server); - Bukkit.setServer(server); - + /* PowerMockito.mockStatic(NamespacedKey.class); NamespacedKey keyValue = mock(NamespacedKey.class); when(NamespacedKey.minecraft(anyString())).thenReturn(keyValue); - + */ when(addon.inRegisteredWorld(any())).thenReturn(true); when(config.getString(anyString())).thenReturn("[WELCOME]"); when(addon.getConfig()).thenReturn(config); @@ -127,14 +133,16 @@ public void setUp() { } when(block.getType()).thenReturn(sign); when(block.getWorld()).thenReturn(world); + // Player when(player.hasPermission(anyString())).thenReturn(false); UUID uuid = UUID.randomUUID(); when(player.getUniqueId()).thenReturn(uuid); when(player.spigot()).thenReturn(spigot); - s = mock(Sign.class); when(s.getLine(anyInt())).thenReturn(ChatColor.GREEN + "[WELCOME]"); when(block.getState()).thenReturn(s); + when(s.getSide(any())).thenReturn(signSide); + when(signSide.getLine(anyInt())).thenReturn(ChatColor.GREEN + "[WELCOME]"); // warp signs manager when(addon.getWarpSignsManager()).thenReturn(wsm); Map list = new HashMap<>(); @@ -198,6 +206,13 @@ public void setUp() { } + @After + public void tearDown() throws Exception { + ServerMocks.unsetBukkitServer(); + User.clearUsers(); + Mockito.framework().clearInlineMocks(); + } + /** * Check that spigot sent the message * @param message - message to check @@ -263,10 +278,10 @@ public void testOnSignNotGameWorld() { public void testOnSignNotWelcomeSign() { WarpSignsListener wsl = new WarpSignsListener(addon); BlockBreakEvent e = new BlockBreakEvent(block, player); - when(s.getLine(Mockito.anyInt())).thenReturn(ChatColor.RED + "[WELCOME]"); + when(signSide.getLine(Mockito.anyInt())).thenReturn(ChatColor.RED + "[WELCOME]"); wsl.onSignBreak(e); assertFalse(e.isCancelled()); - verify(s).getLine(0); + verify(signSide).getLine(0); verify(settings).getWelcomeLine(); } @@ -392,7 +407,7 @@ public void testOnFlagChangeWhenSettingIsOnWarpGetsRemoved() { Map warps = Map.of( player.getUniqueId(), new PlayerWarp(block.getLocation(), true) - ); + ); when(wsm.getWarpMap(any())).thenReturn(warps); when(island.inIslandSpace(any(Location.class))).thenReturn(true); @@ -435,7 +450,7 @@ public void testOnCreateWrongText() { WarpSignsListener wsl = new WarpSignsListener(addon); SignChangeEvent e = new SignChangeEvent(block, player, lines); wsl.onSignWarpCreate(e); - verify(settings).getWelcomeLine(); + verify(settings, times(2)).getWelcomeLine(); checkNoSpigotMessages(); } @@ -488,7 +503,7 @@ public void testCreateNoSignDeactivateOldSign() { wsl.onSignWarpCreate(e); this.checkSpigotMessage("warps.success"); assertEquals(ChatColor.GREEN + "[WELCOME]", e.getLine(0)); - verify(s).setLine(0, ChatColor.RED + "[WELCOME]"); + verify(signSide).setLine(0, ChatColor.RED + "[WELCOME]"); } diff --git a/src/test/java/world/bentobox/warps/mocks/ServerMocks.java b/src/test/java/world/bentobox/warps/mocks/ServerMocks.java new file mode 100644 index 0000000..4496f6d --- /dev/null +++ b/src/test/java/world/bentobox/warps/mocks/ServerMocks.java @@ -0,0 +1,189 @@ +package world.bentobox.warps.mocks; + +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.Server; +import org.bukkit.Tag; +import org.bukkit.UnsafeValues; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; + +import io.papermc.paper.ServerBuildInfo; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; + +/** + * Utility class for creating mocked instances of the Bukkit Server and its associated components. + * This is used primarily for testing purposes. + */ +public final class ServerMocks { + + /** + * Mock implementation of the Paper RegistryAccess interface. + */ + private static class MockRegistryAccess implements RegistryAccess { + @Override + public Registry getRegistry(RegistryKey registryKey) { + @SuppressWarnings("unchecked") + Registry registry = mock(Registry.class); // Return a mocked Registry for the given key. + return registry; + } + + @Override + public @Nullable Registry getRegistry(Class type) { + @SuppressWarnings("unchecked") + Registry registry = mock(Registry.class); // Return a mocked Registry for the given type. + return registry; + } + } + + /** + * Creates and returns a mocked Server instance with all necessary dependencies mocked. + * + * @return a mocked Server instance + */ + public static @NonNull Server newServer() { + // Mock the static ServerBuildInfo class to return mock data + PowerMockito.mockStatic(ServerBuildInfo.class, Mockito.RETURNS_MOCKS); + ServerBuildInfo sbi = mock(io.papermc.paper.ServerBuildInfo.class); + when(ServerBuildInfo.buildInfo()).thenReturn(sbi); + when(sbi.asString(io.papermc.paper.ServerBuildInfo.StringRepresentation.VERSION_FULL)) + .thenReturn("1.21.8-R0.1-SNAPSHOT"); + + // Mock the Server object + Server serverMock = mock(Server.class); + + // Mock a no-op Logger + Logger noOp = mock(Logger.class); + when(serverMock.getLogger()).thenReturn(noOp); + when(serverMock.isPrimaryThread()).thenReturn(true); + when(serverMock.getVersion()).thenReturn("123"); + + // Mock UnsafeValues for unsafe operations + UnsafeValues unsafe = mock(UnsafeValues.class); + when(serverMock.getUnsafe()).thenReturn(unsafe); + + // Mock Paper's RegistryAccess functionality + mockPaperRegistryAccess(); + + // Set the mocked server as the active Bukkit server + Bukkit.setServer(serverMock); + + // Mock registries for Bukkit static constants + Map, Object> registers = new HashMap<>(); + doAnswer(invocationGetRegistry -> registers.computeIfAbsent(invocationGetRegistry.getArgument(0), clazz -> { + Registry registry = mock(Registry.class); + Map cache = new HashMap<>(); + doAnswer(invocationGetEntry -> { + NamespacedKey key = invocationGetEntry.getArgument(0); + + // Determine the class type of the keyed object from the field name + Class constantClazz; + try { + constantClazz = (Class) clazz + .getField(key.getKey().toUpperCase(Locale.ROOT).replace('.', '_')).getType(); + } catch (ClassCastException | NoSuchFieldException e) { + e.printStackTrace(); + return null; + } + + // Cache and return mocked Keyed instances + return cache.computeIfAbsent(key, key1 -> { + Keyed keyed = mock(constantClazz); + doReturn(key).when(keyed).getKey(); + return keyed; + }); + }).when(registry).get((NamespacedKey) notNull()); + return registry; + })).when(serverMock).getRegistry(notNull()); + + // Mock Tags functionality + doAnswer(invocationGetTag -> { + Tag tag = mock(Tag.class); + doReturn(invocationGetTag.getArgument(1)).when(tag).getKey(); + doReturn(Set.of()).when(tag).getValues(); + doAnswer(invocationIsTagged -> { + Keyed keyed = invocationIsTagged.getArgument(0); + Class type = invocationGetTag.getArgument(2); + + // Verify if the Keyed object matches the tag + return type.isAssignableFrom(keyed.getClass()) && (tag.getValues().contains(keyed) + || tag.getValues().stream().anyMatch(value -> value.getKey().equals(keyed.getKey()))); + }).when(tag).isTagged(notNull()); + return tag; + }).when(serverMock).getTag(notNull(), notNull(), notNull()); + + // Initialize certain Bukkit classes that rely on static constants + try { + Class.forName("org.bukkit.inventory.ItemType"); + Class.forName("org.bukkit.block.BlockType"); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + + return serverMock; + } + + /** + * Mocks Paper's RegistryAccess functionality by replacing the RegistryAccess singleton. + */ + private static void mockPaperRegistryAccess() { + try { + RegistryAccess registryAccess = new MockRegistryAccess(); + + // Use Unsafe to modify the singleton instance of RegistryAccessHolder + Field theUnsafe = Class.forName("jdk.internal.misc.Unsafe").getDeclaredField("theUnsafe"); + theUnsafe.setAccessible(true); + Object unsafe = theUnsafe.get(null); + + Field instanceField = Class.forName("io.papermc.paper.registry.RegistryAccessHolder") + .getDeclaredField("INSTANCE"); + Method staticFieldBase = unsafe.getClass().getMethod("staticFieldBase", Field.class); + Method staticFieldOffset = unsafe.getClass().getMethod("staticFieldOffset", Field.class); + Method putObject = unsafe.getClass().getMethod("putObject", Object.class, long.class, Object.class); + + Object base = staticFieldBase.invoke(unsafe, instanceField); + long offset = (long) staticFieldOffset.invoke(unsafe, instanceField); + putObject.invoke(unsafe, base, offset, Optional.of(registryAccess)); + + } catch (Exception e) { + throw new RuntimeException("Failed to mock Paper RegistryAccess", e); + } + } + + /** + * Resets the Bukkit server instance to null. This is useful for cleaning up after tests. + */ + public static void unsetBukkitServer() { + try { + Field server = Bukkit.class.getDeclaredField("server"); + server.setAccessible(true); + server.set(null, null); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + // Private constructor to prevent instantiation + private ServerMocks() { + } +} \ No newline at end of file