From ac0353315c587bc8b062f437fae92eb4444f1e64 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 15:13:49 -0600 Subject: [PATCH 1/2] Add shared platform services with Bukkit/Folia adapters --- .../advancedcore/api/misc/MiscUtils.java | 25 +-- .../platform/BukkitPlatformServices.java | 149 ++++++++++++++ .../platform/ConsoleCommandDispatcher.java | 29 +++ .../core/platform/PlatformPlayer.java | 30 +++ .../core/platform/PlatformScheduler.java | 31 +++ .../core/platform/PlatformServices.java | 22 ++ .../platform/BukkitPlatformServicesTest.java | 193 ++++++++++++++++++ .../MiscUtilsPlatformCommandsTest.java | 58 ++++++ .../platform/PlatformServicesFixture.java | 107 ++++++++++ .../PlatformServicesHeadlessTest.java | 45 ++++ docs/shared-platform-services.md | 88 ++++++++ 11 files changed, 758 insertions(+), 19 deletions(-) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/ConsoleCommandDispatcher.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformPlayer.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformServices.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/MiscUtilsPlatformCommandsTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesFixture.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesHeadlessTest.java create mode 100644 docs/shared-platform-services.md diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java index f0a8276fa2..7680cc0c14 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java @@ -41,6 +41,8 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.bukkit.platform.BukkitPlatformServices; +import com.bencodez.advancedcore.core.platform.ConsoleCommandDispatcher; import com.bencodez.simpleapi.messages.MessageAPI; import com.bencodez.simpleapi.player.PlayerUtils; @@ -60,6 +62,9 @@ public static MiscUtils getInstance() { /** The plugin. */ AdvancedCorePlugin plugin = AdvancedCorePlugin.getInstance(); + private final ConsoleCommandDispatcher consoleCommands = new ConsoleCommandDispatcher( + new BukkitPlatformServices(() -> plugin)); + private MiscUtils() { } @@ -458,25 +463,7 @@ public int getYearFromMili(long time) { } private void runConsoleCommand(String command, int delay, boolean hasDelay) { - final String commandToRun = stripLeadingSlash(command); - if (hasDelay && delay > 0) { - plugin.getBukkitScheduler().runTaskLater(plugin, new Runnable() { - - @Override - public void run() { - Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), commandToRun); - } - }, delay); - - } else { - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - - @Override - public void run() { - Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), commandToRun); - } - }); - } + consoleCommands.dispatch(command, delay, hasDelay); } private String stripLeadingSlash(String command) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java new file mode 100644 index 0000000000..f4dd7635f2 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java @@ -0,0 +1,149 @@ +package com.bencodez.advancedcore.bukkit.platform; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.core.platform.PlatformPlayer; +import com.bencodez.advancedcore.core.platform.PlatformScheduler; +import com.bencodez.advancedcore.core.platform.PlatformServices; + +/** + * Uses the existing plugin-owned SimpleAPI Bukkit/Folia scheduler. Creates no + * executor, listener, task registry, persistent user, or additional player cache. + * The supplier is lazy, including during construction, for legacy facade owners. + */ +public final class BukkitPlatformServices implements PlatformServices { + private final Supplier plugin; + private final PlatformScheduler scheduler = new Scheduler(); + + public BukkitPlatformServices(Supplier plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + } + + @Override + public Optional findOnlinePlayer(UUID playerId) { + Objects.requireNonNull(playerId, "playerId"); + Player player = Bukkit.getPlayer(playerId); + if (player == null || !player.isOnline()) { + return Optional.empty(); + } + return Optional.of(new OnlinePlayer(playerId, player)); + } + + @Override + public PlatformScheduler scheduler() { + return scheduler; + } + + @Override + public boolean dispatchConsoleCommand(String command) { + return Bukkit.getServer().dispatchCommand(Bukkit.getConsoleSender(), command); + } + + @Override + public void sendConsoleMessage(String message) { + Bukkit.getConsoleSender().sendMessage(Objects.requireNonNull(message, "message")); + } + + private AdvancedCorePlugin owner() { + return Objects.requireNonNull(plugin.get(), "plugin is not initialized"); + } + + private final class Scheduler implements PlatformScheduler { + @Override + public void runServer(Runnable task) { + Objects.requireNonNull(task, "task"); + AdvancedCorePlugin owner = owner(); + owner.getBukkitScheduler().runTask(owner, task); + } + + @Override + public void runServerLater(Runnable task, long delaySeconds) { + Objects.requireNonNull(task, "task"); + if (delaySeconds < 0) { + throw new IllegalArgumentException("delaySeconds must not be negative"); + } + AdvancedCorePlugin owner = owner(); + // This existing overload explicitly uses TimeUnit.SECONDS, not ticks. + owner.getBukkitScheduler().runTaskLater(owner, task, delaySeconds); + } + + @Override + public boolean runPlayer(UUID playerId, Consumer task) { + Objects.requireNonNull(playerId, "playerId"); + Objects.requireNonNull(task, "task"); + Player player = Bukkit.getPlayer(playerId); + if (player == null || !player.isOnline()) { + return false; + } + AdvancedCorePlugin owner = owner(); + OnlinePlayer session = new OnlinePlayer(playerId, player); + // Never pass null: SimpleAPI's entity overload otherwise falls back + // to global scheduling. Capture this login, not a future UUID lookup. + owner.getBukkitScheduler().runTask(owner, () -> { + if (owner.isEnabled() && session.isOnline()) { + task.accept(session); + } + }, player); + return true; + } + } + + private static final class OnlinePlayer implements PlatformPlayer { + private final UUID playerId; + private final Player player; + + private OnlinePlayer(UUID playerId, Player player) { + this.playerId = playerId; + this.player = player; + } + + @Override + public UUID getUniqueId() { + return playerId; + } + + @Override + public String getName() { + return player.getName(); + } + + @Override + public boolean isOnline() { + return player.isOnline() && Bukkit.getPlayer(playerId) == player; + } + + @Override + public boolean hasPermission(String permission) { + Objects.requireNonNull(permission, "permission"); + return isOnline() && player.hasPermission(permission); + } + + @Override + public void sendMessage(String message) { + Objects.requireNonNull(message, "message"); + requireOnline(); + player.sendMessage(message); + } + + @Override + public boolean performCommand(String command) { + Objects.requireNonNull(command, "command"); + requireOnline(); + return player.performCommand(command); + } + + private void requireOnline() { + if (!isOnline()) { + throw new IllegalStateException("Player session is no longer online"); + } + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/ConsoleCommandDispatcher.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/ConsoleCommandDispatcher.java new file mode 100644 index 0000000000..fc98e491a4 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/ConsoleCommandDispatcher.java @@ -0,0 +1,29 @@ +package com.bencodez.advancedcore.core.platform; + +import java.util.Objects; + +/** The legacy console-list handoff policy, independent of Bukkit and reward storage. */ +public final class ConsoleCommandDispatcher { + private final PlatformServices platform; + + public ConsoleCommandDispatcher(PlatformServices platform) { + this.platform = Objects.requireNonNull(platform, "platform"); + } + + /** + * Removes exactly one leading slash, then preserves the legacy stagger rule: + * only a positive delay with staggering enabled uses the delayed scheduler. + * Input has already been rendered by the caller. Null/empty lines are not + * filtered here; native dispatch retains responsibility for rejecting them. + * Returning from this method does not mean the command has executed. + */ + public void dispatch(String command, long delaySeconds, boolean stagger) { + String line = command != null && command.startsWith("/") ? command.substring(1) : command; + Runnable dispatch = () -> platform.dispatchConsoleCommand(line); + if (stagger && delaySeconds > 0) { + platform.scheduler().runServerLater(dispatch, delaySeconds); + } else { + platform.scheduler().runServer(dispatch); + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformPlayer.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformPlayer.java new file mode 100644 index 0000000000..fb2ee2fd31 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformPlayer.java @@ -0,0 +1,30 @@ +package com.bencodez.advancedcore.core.platform; + +import java.util.UUID; + +/** + * A live online-player session, not a persistent user or an offline identity. + * Implementations must not redirect a retained handle to a replacement session. + * Except for the UUID, use this handle only on its owning game thread (normally + * inside {@link PlatformScheduler#runPlayer}). Do not retain it across callbacks. + */ +public interface PlatformPlayer { + UUID getUniqueId(); + + String getName(); + + boolean isOnline(); + + /** Checks one literal native permission node; no offline/Vault fallback. */ + boolean hasPermission(String permission); + + /** Sends already-rendered text; no placeholders, scripts, or color parsing. */ + void sendMessage(String message); + + /** + * Runs an already-rendered command as this player, without granting operator + * privileges. The caller supplies the native command line without a slash. + * Returns the native dispatch result, not a durable reward acknowledgement. + */ + boolean performCommand(String command); +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java new file mode 100644 index 0000000000..ce9cdea0a6 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java @@ -0,0 +1,31 @@ +package com.bencodez.advancedcore.core.platform; + +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Game-thread handoff only; no executor, persistent queue, or retry ownership. + * The platform/plugin owns cancellation on disable. Scheduling is not completion: + * accepted work can be retired on shutdown or disconnect. These void/boolean + * methods must not be used as durable reward-completion acknowledgements. + */ +public interface PlatformScheduler { + /** Queues server-wide work. This does not authorize access to player/world state. */ + void runServer(Runnable task); + + /** + * Queues server-wide work using the existing scheduler's seconds-based delay. + * This is not a tick count or a hard wall-clock deadline. Negative delays fail. + */ + void runServerLater(Runnable task, long delaySeconds); + + /** + * Queues work on the current online session's owning entity/game thread. + * Returns false when no online session exists at submission; true means only + * submitted. A disconnected/replaced session must never fall back to a global + * callback or transfer the action to a new login. The callback may therefore + * not run. Rejection exceptions propagate; task failures belong to the native + * scheduler. Do not block on, retry, or acknowledge rewards from this boolean. + */ + boolean runPlayer(UUID playerId, Consumer task); +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformServices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformServices.java new file mode 100644 index 0000000000..67a0ba5519 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformServices.java @@ -0,0 +1,22 @@ +package com.bencodez.advancedcore.core.platform; + +import java.util.Optional; +import java.util.UUID; + +/** Minimal game services used by shared code; no Bukkit or mod-loader types. */ +public interface PlatformServices { + /** + * Read-only UUID lookup; never creates a persistent/offline user or cache. + * Use the returned session only on its owning game thread. For asynchronous + * callers, prefer {@link PlatformScheduler#runPlayer} for lookup plus handoff. + */ + Optional findOnlinePlayer(UUID playerId); + + PlatformScheduler scheduler(); + + /** Native synchronous dispatch; call from server-wide game context, not async. */ + boolean dispatchConsoleCommand(String command); + + /** Sends already-rendered text from server-wide game context. */ + void sendConsoleMessage(String message); +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java new file mode 100644 index 0000000000..13b70c7150 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java @@ -0,0 +1,193 @@ +package com.bencodez.advancedcore.tests.platform; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.bukkit.platform.BukkitPlatformServices; +import com.bencodez.advancedcore.core.platform.PlatformPlayer; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class BukkitPlatformServicesTest { + @Test + void constructionAndSchedulerAccessDoNotResolvePlugin() { + BukkitPlatformServices services = new BukkitPlatformServices(() -> { throw new AssertionError("eager"); }); + assertSame(services.scheduler(), services.scheduler()); + assertThrows(NullPointerException.class, () -> new BukkitPlatformServices(null)); + } + + @Test + void serverSchedulingUsesExistingOwnerAndSecondsWithoutNewExecutors() { + AdvancedCorePlugin first = mock(AdvancedCorePlugin.class); + AdvancedCorePlugin second = mock(AdvancedCorePlugin.class); + BukkitScheduler firstScheduler = mock(BukkitScheduler.class); + BukkitScheduler secondScheduler = mock(BukkitScheduler.class); + when(first.getBukkitScheduler()).thenReturn(firstScheduler); + when(second.getBukkitScheduler()).thenReturn(secondScheduler); + AtomicReference owner = new AtomicReference<>(first); + BukkitPlatformServices services = new BukkitPlatformServices(owner::get); + Runnable task = mock(Runnable.class); + services.scheduler().runServer(task); + verify(firstScheduler).runTask(first, task); + owner.set(second); + services.scheduler().runServerLater(task, 7); + verify(secondScheduler).runTaskLater(second, task, 7L); + verifyNoInteractions(task); + assertThrows(IllegalArgumentException.class, () -> services.scheduler().runServerLater(task, -1)); + verifyNoMoreInteractions(firstScheduler, secondScheduler); + } + + @Test + void consoleDispatchPreservesNativeResultTextAndException() { + BukkitPlatformServices services = new BukkitPlatformServices(() -> null); + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + when(server.dispatchCommand(console, "literal %player%")).thenReturn(true); + assertTrue(services.dispatchConsoleCommand("literal %player%")); + assertFalse(services.dispatchConsoleCommand("unknown")); + services.sendConsoleMessage("&a[Javascript=literal]"); + verify(console).sendMessage("&a[Javascript=literal]"); + RuntimeException failure = new IllegalStateException("dispatch failed"); + when(server.dispatchCommand(console, "bad")).thenThrow(failure); + assertSame(failure, assertThrows(IllegalStateException.class, () -> services.dispatchConsoleCommand("bad"))); + } + } + + @Test + void missingPlayerDoesNotCreateOfflineUserOrFallBackToGlobalTask() { + UUID id = UUID.randomUUID(); + BukkitPlatformServices services = new BukkitPlatformServices(() -> { throw new AssertionError("owner lookup"); }); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + assertTrue(services.findOnlinePlayer(id).isEmpty()); + assertFalse(services.scheduler().runPlayer(id, p -> fail("offline callback"))); + bukkit.verify(() -> Bukkit.getPlayer(id), times(2)); + bukkit.verifyNoMoreInteractions(); + } + } + + @Test + void playerHandoffUsesEntityOverloadAndDoesNotRunEarly() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + Player nativePlayer = mock(Player.class); + UUID id = UUID.randomUUID(); + when(nativePlayer.isOnline()).thenReturn(true); + when(nativePlayer.getName()).thenReturn("Ben"); + when(nativePlayer.hasPermission("allowed")).thenReturn(true); + when(nativePlayer.performCommand("say hi")).thenReturn(true); + BukkitPlatformServices services = new BukkitPlatformServices(() -> plugin); + AtomicInteger calls = new AtomicInteger(); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(nativePlayer); + assertTrue(services.scheduler().runPlayer(id, player -> { + calls.incrementAndGet(); + assertEquals(id, player.getUniqueId()); + assertEquals("Ben", player.getName()); + assertTrue(player.isOnline()); + assertTrue(player.hasPermission("allowed")); + assertFalse(player.hasPermission("denied")); + player.sendMessage("&a%literal%"); + assertTrue(player.performCommand("say hi")); + assertFalse(player.performCommand("unknown")); + })); + assertEquals(0, calls.get()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), task.capture(), same(nativePlayer)); + verify(scheduler, never()).runTask(any(), any(Runnable.class)); + task.getValue().run(); + assertEquals(1, calls.get()); + verify(nativePlayer).sendMessage("&a%literal%"); + verify(nativePlayer, never()).setOp(anyBoolean()); + } + } + + @Test + void disconnectReplacementAndDisableRetireQueuedPlayerWork() { + for (String reason : new String[] {"disconnect", "replacement", "disable"}) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + UUID id = UUID.randomUUID(); + BukkitPlatformServices services = new BukkitPlatformServices(() -> plugin); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + assertTrue(services.scheduler().runPlayer(id, p -> fail(reason))); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), task.capture(), same(player)); + if (reason.equals("disconnect")) when(player.isOnline()).thenReturn(false); + if (reason.equals("replacement")) bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(mock(Player.class)); + if (reason.equals("disable")) when(plugin.isEnabled()).thenReturn(false); + task.getValue().run(); + verify(scheduler, never()).runTask(any(), any(Runnable.class)); + } + } + } + + @Test + void retainedHandleNeverRedirectsActionsToNewLogin() { + UUID id = UUID.randomUUID(); + Player first = mock(Player.class); + Player replacement = mock(Player.class); + when(first.isOnline()).thenReturn(true); + BukkitPlatformServices services = new BukkitPlatformServices(() -> null); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(first); + PlatformPlayer player = services.findOnlinePlayer(id).orElseThrow(); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(replacement); + assertFalse(player.isOnline()); + assertFalse(player.hasPermission("admin")); + assertThrows(IllegalStateException.class, () -> player.sendMessage("old")); + assertThrows(IllegalStateException.class, () -> player.performCommand("old")); + verify(first, never()).hasPermission(anyString()); + verifyNoInteractions(replacement); + } + } + + @Test + void schedulerAndCallbackFailuresAreNotSwallowed() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + BukkitPlatformServices services = new BukkitPlatformServices(() -> plugin); + IllegalStateException failure = new IllegalStateException("stopped"); + doThrow(failure).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + assertSame(failure, assertThrows(IllegalStateException.class, () -> services.scheduler().runServer(() -> { }))); + UUID id = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(true); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + doThrow(failure).when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class)); + assertSame(failure, assertThrows(IllegalStateException.class, + () -> services.scheduler().runPlayer(id, p -> { }))); + reset(scheduler); + services.scheduler().runPlayer(id, p -> { throw failure; }); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), task.capture(), same(player)); + assertSame(failure, assertThrows(IllegalStateException.class, task.getValue()::run)); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/MiscUtilsPlatformCommandsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/MiscUtilsPlatformCommandsTest.java new file mode 100644 index 0000000000..cc3e62fe55 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/MiscUtilsPlatformCommandsTest.java @@ -0,0 +1,58 @@ +package com.bencodez.advancedcore.tests.platform; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class MiscUtilsPlatformCommandsTest { + @Test + void existingListFacadeUsesSharedDispatcherWithoutChangingRenderingOrTiming() throws Exception { + MiscUtils facade = MiscUtils.getInstance(); + Field field = MiscUtils.class.getDeclaredField("plugin"); + field.setAccessible(true); + Object previous = field.get(facade); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + Server server = mock(Server.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + field.set(facade, plugin); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + HashMap placeholders = new HashMap<>(); + placeholders.put("who", "Ben"); + facade.executeConsoleCommands(new ArrayList<>(List.of("/say %who%", "//literal", " third")), placeholders, true); + verifyNoInteractions(server); + ArgumentCaptor first = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor second = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor third = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), first.capture()); + verify(scheduler).runTaskLater(eq(plugin), second.capture(), eq(1L)); + verify(scheduler).runTaskLater(eq(plugin), third.capture(), eq(2L)); + first.getValue().run(); second.getValue().run(); third.getValue().run(); + org.mockito.InOrder ordered = inOrder(server); + ordered.verify(server).dispatchCommand(console, "say Ben"); + ordered.verify(server).dispatchCommand(console, "/literal"); + ordered.verify(server).dispatchCommand(console, " third"); + verifyNoMoreInteractions(server); + } finally { + field.set(facade, previous); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesFixture.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesFixture.java new file mode 100644 index 0000000000..435330dc3f --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesFixture.java @@ -0,0 +1,107 @@ +package com.bencodez.advancedcore.tests.platform; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import com.bencodez.advancedcore.core.platform.ConsoleCommandDispatcher; +import com.bencodez.advancedcore.core.platform.PlatformPlayer; +import com.bencodez.advancedcore.core.platform.PlatformScheduler; +import com.bencodez.advancedcore.core.platform.PlatformServices; + +/** JDK-only behavioral fixture, also executed in a no-server-API class loader. */ +public final class PlatformServicesFixture { + private PlatformServicesFixture() { } + + public static void main(String[] args) { + run(); + System.out.println("PlatformServicesFixture passed"); + } + + public static void run() { + FakePlatform platform = new FakePlatform(); + ConsoleCommandDispatcher dispatcher = new ConsoleCommandDispatcher(platform); + String[] input = {"/one", "//two", " /three", "[Javascript=literal]", "", null}; + String[] expected = {"one", "/two", " /three", "[Javascript=literal]", "", null}; + for (int i = 0; i < input.length; i++) dispatcher.dispatch(input[i], i, true); + require(platform.commands.isEmpty(), "dispatch happened before handoff"); + require(platform.delays.equals(List.of(0L, 1L, 2L, 3L, 4L, 5L)), "stagger changed"); + platform.pending.forEach(Runnable::run); + require(platform.commands.equals(java.util.Arrays.asList(expected)), "command text changed"); + + FakePlatform immediate = new FakePlatform(); + ConsoleCommandDispatcher other = new ConsoleCommandDispatcher(immediate); + other.dispatch("one", 7, false); + other.dispatch("two", -2, true); + other.dispatch("three", 0, true); + require(immediate.delays.equals(List.of(0L, 0L, 0L)), "immediate branch changed"); + + FakePlayer player = new FakePlayer(); + platform.player = player; + require(platform.findOnlinePlayer(player.id).orElseThrow() == player, "UUID lookup"); + require(platform.findOnlinePlayer(UUID.randomUUID()).isEmpty(), "unknown UUID"); + require(!platform.runPlayer(UUID.randomUUID(), p -> { throw new AssertionError("offline"); }), "offline handoff"); + platform.runPlayer(player.id, p -> { + require(p.getUniqueId().equals(player.id) && p.getName().equals("Ben"), "identity"); + require(!p.hasPermission("denied"), "permission denial"); + if (p.hasPermission("allowed")) p.sendMessage("already rendered"); + require(p.performCommand("say literal"), "player command result"); + }); + platform.pending.get(platform.pending.size() - 1).run(); + require(player.messages.equals(List.of("already rendered")), "message"); + require(player.commands.equals(List.of("say literal")), "player command"); + + FakePlatform rejected = new FakePlatform(); + RuntimeException failure = new IllegalStateException("scheduler stopped"); + rejected.failure = failure; + try { + new ConsoleCommandDispatcher(rejected).dispatch("one", 0, false); + throw new AssertionError("rejection hidden"); + } catch (IllegalStateException actual) { + require(actual == failure, "rejection replaced"); + } + require(rejected.commands.isEmpty(), "rejection dispatched work"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static final class FakePlatform implements PlatformServices, PlatformScheduler { + private final List pending = new ArrayList<>(); + private final List delays = new ArrayList<>(); + private final List commands = new ArrayList<>(); + private FakePlayer player; + private RuntimeException failure; + @Override public Optional findOnlinePlayer(UUID id) { + return player != null && player.id.equals(id) ? Optional.of(player) : Optional.empty(); + } + @Override public PlatformScheduler scheduler() { return this; } + @Override public boolean dispatchConsoleCommand(String command) { commands.add(command); return true; } + @Override public void sendConsoleMessage(String message) { } + @Override public void runServer(Runnable task) { runServerLater(task, 0); } + @Override public void runServerLater(Runnable task, long seconds) { + if (failure != null) throw failure; + pending.add(task); delays.add(seconds); + } + @Override public boolean runPlayer(UUID id, Consumer task) { + Optional target = findOnlinePlayer(id); + target.ifPresent(p -> pending.add(() -> task.accept(p))); + return target.isPresent(); + } + } + + private static final class FakePlayer implements PlatformPlayer { + private final UUID id = UUID.randomUUID(); + private final List messages = new ArrayList<>(); + private final List commands = new ArrayList<>(); + @Override public UUID getUniqueId() { return id; } + @Override public String getName() { return "Ben"; } + @Override public boolean isOnline() { return true; } + @Override public boolean hasPermission(String permission) { return "allowed".equals(permission); } + @Override public void sendMessage(String message) { messages.add(message); } + @Override public boolean performCommand(String command) { commands.add(command); return true; } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesHeadlessTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesHeadlessTest.java new file mode 100644 index 0000000000..2c126292ad --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/PlatformServicesHeadlessTest.java @@ -0,0 +1,45 @@ +package com.bencodez.advancedcore.tests.platform; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URL; +import java.net.URLClassLoader; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.core.platform.PlatformServices; + +class PlatformServicesHeadlessTest { + @Test + void commandHandoffAndPlayerContractBehavior() { + PlatformServicesFixture.run(); + } + + @Test + void sharedServicesRunWithoutAnyServerOrTestFramework() throws Exception { + URL[] urls = { + PlatformServices.class.getProtectionDomain().getCodeSource().getLocation(), + PlatformServicesFixture.class.getProtectionDomain().getCodeSource().getLocation() + }; + try (URLClassLoader isolated = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.startsWith("org.bukkit.") || name.startsWith("io.papermc.") + || name.startsWith("net.minecraft.") || name.startsWith("net.md_5.") + || name.startsWith("net.fabricmc.") || name.startsWith("net.minecraftforge.") + || name.startsWith("net.neoforged.") || name.startsWith("com.bencodez.simpleapi.") + || name.startsWith("com.bencodez.advancedcore.bukkit.") + || name.startsWith("com.bencodez.advancedcore.api.") + || name.equals("com.bencodez.advancedcore.AdvancedCorePlugin") + || name.startsWith("org.junit.") || name.startsWith("org.mockito.")) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name, resolve); + } + }) { + assertThrows(ClassNotFoundException.class, () -> isolated.loadClass("org.bukkit.Bukkit")); + assertThrows(ClassNotFoundException.class, () -> isolated.loadClass("org.junit.jupiter.api.Test")); + isolated.loadClass(PlatformServicesFixture.class.getName()).getMethod("run").invoke(null); + } + } +} diff --git a/docs/shared-platform-services.md b/docs/shared-platform-services.md new file mode 100644 index 0000000000..1e83a8fa99 --- /dev/null +++ b/docs/shared-platform-services.md @@ -0,0 +1,88 @@ +# Shared platform services + +The `core.platform` package now has minimal `PlatformServices`, `PlatformPlayer`, +and `PlatformScheduler` contracts. `BukkitPlatformServices` adapts the existing +plugin-owned SimpleAPI Bukkit/Folia scheduler; it does not create another runtime, +executor, service registry, persistent user, or player cache. + +## Native consumers + +Shared code receives `PlatformServices` through its constructor. The Bukkit entry +point supplies `new BukkitPlatformServices(() -> plugin)`; a future loader supplies +its own implementation. Construction does not resolve the plugin or touch Bukkit. +There are no Fabric, Forge, or NeoForge implementations or new artifacts yet. + +```java +platform.scheduler().runPlayer(playerId, player -> { + if (player.hasPermission("example.message")) { + player.sendMessage("Already-rendered message"); + } +}); +platform.scheduler().runServer(() -> platform.dispatchConsoleCommand("say hello")); +``` + +`findOnlinePlayer(UUID)` is a read-only online-session lookup, not offline UUID/name +resolution or an `AdvancedCoreUser` lookup. It does not invoke Vault, offline +LuckPerms checks, SQL, or FLAT storage. The player permission method checks one +literal native node and does not add prefixes, split alternatives, or grant OP. +Existing AdvancedCore permission helpers and their provider/fallback rules remain +unchanged. + +Player operations must run on that player's owning game thread. A synchronous +lookup does not grant thread ownership; asynchronous consumers should use +`runPlayer`. Its callback uses the captured entity's scheduler, not the global or +location scheduler. If that login disconnects, is replaced, or the plugin is +disabled before the callback, it will not execute the action. A stale handle +returns false for permission checks and rejects messages/commands; it does not +look up a replacement login and redirect the operation. + +Server-wide callbacks must not access region-confined worlds or players. Console +command dispatch belongs in that context, but called commands are still responsible +for their own Folia safety. Raw message/command methods do not expand placeholders, +color codes, JSON, or JavaScript. `sendMessage` is the basic native text transport, +not a replacement for AdvancedCore's rich-message renderer. + +## Scheduling contract + +`runServerLater` uses **seconds**, matching SimpleAPI's existing `runTaskLater` +overload. It is not a tick count and does not guarantee a wall-clock deadline. +Negative delays are rejected. `runServer` uses the existing `runTask` handoff. +`runPlayer` never supplies a null entity to SimpleAPI (which would fall back to +global execution). The plugin's existing scheduler owns shutdown cancellation; +no parallel lifecycle or background work is introduced here. + +A true `runPlayer` result means submitted, **not completed**. Disconnect/retirement +or shutdown can prevent callbacks from running. Rejection and callback exceptions +are not swallowed. These methods provide no durable completion acknowledgement, +retry policy, persistence, timeout, or exactly-once guarantee; do not use their +return values to checkpoint rewards. Ordered asynchronous reward execution and +its completion boundaries remain separate work in PR #317. + +## Existing production integration + +Only the private legacy `MiscUtils.runConsoleCommand` implementation delegates to +`ConsoleCommandDispatcher`, using a lazily resolved Bukkit adapter. Existing public +command-list helpers exercise the new shared scheduler and command service now. + +The caller's placeholder/script parsing, logging, list iteration, and public +signatures are unchanged. The shared dispatcher removes exactly one leading slash +and preserves the old stagger branch: a positive delay uses the delayed scheduler +only when staggering is enabled. All other cases use `runTask`. It does not filter +null/empty results, re-render text, or turn scheduled submission into completion. +Single-command overloads, the separate slash helper, and async reward methods are +not changed. PR #317 also touches `MiscUtils`; preserve its async additions when +combining branches, and rerun both suites. + +## Validation + +The existing command remains `mvn -B -f AdvancedCore/pom.xml package` on Java 21. +Tests cover core command policy in a JDK-only fixture and isolated classloader, +Bukkit UUID lookup/native dispatch, entity-versus-global routing, stale sessions, +disable, failures, lazy ownership, and the existing command-list facade. No new +workflow, module, dependency pin, or test skip is introduced. SimpleAPI remains +`1.0.2-SNAPSHOT`; record the actual resolved snapshot when validating downstream. + +Before release, build SimpleAPI, AdvancedCore, and VotingPlugin against the actual +candidate artifacts and test the packaged plugin on Bukkit/Paper and Folia. +Mocked scheduler routing and core classloader tests are not live-server or +packaged-native-mod validation. From 264234e2004eae203060e6bb2e6863f3f693d01f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 15:39:41 -0600 Subject: [PATCH 2/2] fix: defer player state checks until entity-thread handoff --- .../platform/BukkitPlatformServices.java | 4 +- .../core/platform/PlatformScheduler.java | 13 ++-- .../platform/BukkitPlatformServicesTest.java | 73 +++++++++++++++++++ docs/shared-platform-services.md | 16 ++-- 4 files changed, 94 insertions(+), 12 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java index f4dd7635f2..9d1382ca82 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/platform/BukkitPlatformServices.java @@ -79,8 +79,10 @@ public void runServerLater(Runnable task, long delaySeconds) { public boolean runPlayer(UUID playerId, Consumer task) { Objects.requireNonNull(playerId, "playerId"); Objects.requireNonNull(task, "task"); + // Async callers may locate the entity, but must not read player + // state until the entity scheduler transfers us to its owning thread. Player player = Bukkit.getPlayer(playerId); - if (player == null || !player.isOnline()) { + if (player == null) { return false; } AdvancedCorePlugin owner = owner(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java index ce9cdea0a6..4f6aac1bf6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/PlatformScheduler.java @@ -21,11 +21,14 @@ public interface PlatformScheduler { /** * Queues work on the current online session's owning entity/game thread. - * Returns false when no online session exists at submission; true means only - * submitted. A disconnected/replaced session must never fall back to a global - * callback or transfer the action to a new login. The callback may therefore - * not run. Rejection exceptions propagate; task failures belong to the native - * scheduler. Do not block on, retry, or acknowledge rewards from this boolean. + * Async callers may locate the entity for scheduling, but must defer player + * state reads (including online checks) to the owning-thread callback. + * Returns false when UUID lookup finds no player; true means only submitted, + * not that the captured session is still online. A disconnected/replaced + * session must never fall back to a global callback or transfer the action + * to a new login. The callback may therefore not run. Rejection exceptions + * propagate; task failures belong to the native scheduler. Do not block on, + * retry, or acknowledge rewards from this boolean. */ boolean runPlayer(UUID playerId, Consumer task); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java index 13b70c7150..53c94631e4 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/platform/BukkitPlatformServicesTest.java @@ -5,6 +5,9 @@ import static org.mockito.Mockito.*; import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -120,6 +123,76 @@ void playerHandoffUsesEntityOverloadAndDoesNotRunEarly() { } } + @Test + void asyncPlayerHandoffDoesNotReadEntityStateOnCallerThread() throws Exception { + Thread entityThread = Thread.currentThread(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + Player player = mock(Player.class); + UUID id = UUID.randomUUID(); + when(player.isOnline()).thenAnswer(invocation -> { + assertSame(entityThread, Thread.currentThread(), "Player state read before entity handoff"); + return true; + }); + BukkitPlatformServices services = new BukkitPlatformServices(() -> plugin); + AtomicInteger calls = new AtomicInteger(); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + assertTrue(worker.submit(() -> { + assertNotSame(entityThread, Thread.currentThread()); + // Static mocks are thread-scoped: install lookup on the submitting thread. + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + return services.scheduler().runPlayer(id, session -> { + assertSame(entityThread, Thread.currentThread()); + assertEquals(id, session.getUniqueId()); + calls.incrementAndGet(); + }); + } + }).get(5, TimeUnit.SECONDS)); + verifyNoInteractions(player); + assertEquals(0, calls.get()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), task.capture(), same(player)); + verify(scheduler, never()).runTask(any(), any(Runnable.class)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + task.getValue().run(); + } + verify(player).isOnline(); + assertEquals(1, calls.get()); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS), "Worker did not terminate"); + } + } + + @Test + void alreadyDisconnectedPlayerIsCheckedOnlyInsideEntityCallback() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + Player player = mock(Player.class); + when(player.isOnline()).thenReturn(false); + UUID id = UUID.randomUUID(); + BukkitPlatformServices services = new BukkitPlatformServices(() -> plugin); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + // Lookup can still find the entity while its login is being retired. + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + assertTrue(services.scheduler().runPlayer(id, session -> fail("disconnected callback"))); + verifyNoInteractions(player); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), task.capture(), same(player)); + task.getValue().run(); + verify(player).isOnline(); + verifyNoMoreInteractions(player); + verify(scheduler, never()).runTask(any(), any(Runnable.class)); + } + } + @Test void disconnectReplacementAndDisableRetireQueuedPlayerWork() { for (String reason : new String[] {"disconnect", "replacement", "disable"}) { diff --git a/docs/shared-platform-services.md b/docs/shared-platform-services.md index 1e83a8fa99..cecd86e5c4 100644 --- a/docs/shared-platform-services.md +++ b/docs/shared-platform-services.md @@ -30,9 +30,11 @@ unchanged. Player operations must run on that player's owning game thread. A synchronous lookup does not grant thread ownership; asynchronous consumers should use -`runPlayer`. Its callback uses the captured entity's scheduler, not the global or -location scheduler. If that login disconnects, is replaced, or the plugin is -disabled before the callback, it will not execute the action. A stale handle +`runPlayer`. Submission only locates the entity by UUID; it does not read player +state such as `isOnline()` on the caller's thread. Online/session checks run inside +the captured entity's scheduler callback, not on the global or location scheduler. +If that login disconnects, is replaced, or the plugin is disabled before the +callback, it will not execute the action. A stale handle returns false for permission checks and rejects messages/commands; it does not look up a replacement login and redirect the operation. @@ -51,7 +53,8 @@ Negative delays are rejected. `runServer` uses the existing `runTask` handoff. global execution). The plugin's existing scheduler owns shutdown cancellation; no parallel lifecycle or background work is introduced here. -A true `runPlayer` result means submitted, **not completed**. Disconnect/retirement +A false `runPlayer` result means UUID lookup found no player to schedule. A true +result means submitted, **not completed** or confirmed online. Disconnect/retirement or shutdown can prevent callbacks from running. Rejection and callback exceptions are not swallowed. These methods provide no durable completion acknowledgement, retry policy, persistence, timeout, or exactly-once guarantee; do not use their @@ -78,8 +81,9 @@ combining branches, and rerun both suites. The existing command remains `mvn -B -f AdvancedCore/pom.xml package` on Java 21. Tests cover core command policy in a JDK-only fixture and isolated classloader, Bukkit UUID lookup/native dispatch, entity-versus-global routing, stale sessions, -disable, failures, lazy ownership, and the existing command-list facade. No new -workflow, module, dependency pin, or test skip is introduced. SimpleAPI remains +disable, failures, lazy ownership, worker-thread submission with no early player +state reads, already-disconnected submissions, and the existing command-list facade. +No new workflow, module, dependency pin, or test skip is introduced. SimpleAPI remains `1.0.2-SNAPSHOT`; record the actual resolved snapshot when validating downstream. Before release, build SimpleAPI, AdvancedCore, and VotingPlugin against the actual