diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java index 29f916896..acd43d097 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardEligibility.java @@ -9,6 +9,7 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.LongSupplier; @@ -19,7 +20,6 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; -import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; /** Owns optional eligibility observations and bounded replay wake-ups, never reward payloads. */ public final class RewardEligibility { @@ -94,8 +94,8 @@ public CompletionStage nativeSessionReady(Player player) { /** * Schedules an opted-in player action with explicit Folia retirement handling. - * Callers share their action claim with both terminal callbacks so a late - * task cannot execute after its unstarted work has been retained for retry. + * Dispatch and terminal callbacks share a claim so a late task cannot execute + * after its unstarted work has been retained for retry. */ public static void runPlayerTask(AdvancedCorePlugin plugin, Player player, Runnable action, Runnable retired, Consumer rejected) { @@ -105,14 +105,25 @@ public static void runPlayerTask(AdvancedCorePlugin plugin, Player player, Runna scheduler.runTask(plugin, action, player); return; } - var result = folia.getImpl().runAtEntityWithFallback(player, ignored -> action.run(), retired); + AtomicBoolean claimed = new AtomicBoolean(); + Runnable retireOnce = () -> { + if (claimed.compareAndSet(false, true)) retired.run(); + }; + Consumer rejectOnce = failure -> { + if (claimed.compareAndSet(false, true)) rejected.accept(failure); + }; + // SimpleAPI relocates FoliaLib. Its enum names are stable across shading; + // the shaded and original enum classes are different Java types. + CompletionStage> result = folia.getScheduler().runAtEntityWithFallback(player, + ignored -> { if (claimed.compareAndSet(false, true)) action.run(); }, retireOnce); if (result == null) throw new IllegalStateException("Entity scheduler returned no admission result"); result.whenComplete((status, failure) -> { - if (failure != null) rejected.accept(failure); - else if (status == EntityTaskResult.ENTITY_RETIRED || status == EntityTaskResult.SCHEDULER_RETIRED) { - retired.run(); - } else if (status != EntityTaskResult.SUCCESS) { - rejected.accept(new IllegalStateException("Entity scheduler returned an unknown admission result")); + if (failure != null) rejectOnce.accept(failure); + else { + String name = status == null ? "" : status.name(); + if (name.equals("ENTITY_RETIRED") || name.equals("SCHEDULER_RETIRED")) retireOnce.run(); + else if (!name.equals("SUCCESS")) rejectOnce.accept( + new IllegalStateException("Entity scheduler returned an unknown admission result")); } }); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PlayerTaskAdmissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PlayerTaskAdmissionTest.java new file mode 100644 index 000000000..1ef25c79b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/PlayerTaskAdmissionTest.java @@ -0,0 +1,174 @@ +package com.bencodez.advancedcore.tests.api.rewards; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.Consumer; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.PlatformScheduler; +import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class PlayerTaskAdmissionTest { + private enum RelocatedResult { SUCCESS, ENTITY_RETIRED, SCHEDULER_RETIRED, UNEXPECTED } + + @Test + void successDispatchesOnlyOnTheOwnerCallback() { + Fixture fixture = new Fixture(); + fixture.submit(); + verifyNoInteractions(fixture.action, fixture.retired, fixture.rejected); + fixture.owner.accept(null); + fixture.result.complete(EntityTaskResult.SUCCESS); + verify(fixture.action).run(); + verifyNoInteractions(fixture.retired, fixture.rejected); + } + + @Test + void fallbackAndFutureRetirementNotifyOnlyOnceAndPreventLateDispatch() { + Fixture fixture = new Fixture(); + fixture.submit(); + fixture.fallback.run(); + fixture.result.complete(EntityTaskResult.ENTITY_RETIRED); + fixture.fallback.run(); + fixture.owner.accept(null); + verify(fixture.retired).run(); + verifyNoInteractions(fixture.action, fixture.rejected); + } + + @Test + void immediateSchedulerRetirementDoesNotRequireTheFallback() { + Fixture fixture = new Fixture(); + fixture.result.complete(EntityTaskResult.SCHEDULER_RETIRED); + fixture.submit(); + fixture.fallback.run(); + verify(fixture.retired).run(); + verifyNoInteractions(fixture.action, fixture.rejected); + } + + @Test + void actionClaimPreventsLateRetirementOrExceptionalRejection() { + Fixture fixture = new Fixture(); + fixture.submit(); + fixture.owner.accept(null); + fixture.owner.accept(null); + fixture.fallback.run(); + fixture.result.completeExceptionally(new IllegalStateException("Synthetic late failure")); + verify(fixture.action).run(); + verifyNoInteractions(fixture.retired, fixture.rejected); + } + + @Test + void exceptionalCompletionRejectsOnceAndPreventsLateCallbacks() { + Fixture fixture = new Fixture(); + RuntimeException failure = new IllegalStateException("Synthetic admission failure"); + fixture.submit(); + fixture.result.completeExceptionally(failure); + fixture.fallback.run(); + fixture.owner.accept(null); + verify(fixture.rejected).accept(failure); + verifyNoInteractions(fixture.action, fixture.retired); + } + + @Test + void nullStatusRejectsInsteadOfLosingThePendingAction() { + Fixture fixture = new Fixture(); + fixture.submit(); + fixture.result.complete(null); + verify(fixture.rejected).accept(isA(IllegalStateException.class)); + verifyNoInteractions(fixture.action, fixture.retired); + } + + @Test + void enumNamesRemainCompatibleAfterRelocationAndUnknownStatusRejects() { + for (RelocatedResult status : RelocatedResult.values()) { + Fixture fixture = new Fixture(); + CompletableFuture relocated = new CompletableFuture<>(); + fixture.returnRelocated(relocated); + fixture.submit(); + relocated.complete(status); + if (status == RelocatedResult.SUCCESS) { + fixture.owner.accept(null); + verify(fixture.action).run(); + verifyNoInteractions(fixture.retired, fixture.rejected); + } else if (status == RelocatedResult.UNEXPECTED) { + verify(fixture.rejected).accept(isA(IllegalStateException.class)); + verifyNoInteractions(fixture.action, fixture.retired); + } else { + verify(fixture.retired).run(); + verifyNoInteractions(fixture.action, fixture.rejected); + } + } + } + + @Test + void missingFutureAndSynchronousRejectionKeepTheCallerFailureContract() { + Fixture missing = new Fixture(); + when(missing.platform.runAtEntityWithFallback(any(), any(), any())).thenReturn(null); + assertThrows(IllegalStateException.class, missing::submit); + Fixture rejected = new Fixture(); + RejectedExecutionException failure = new RejectedExecutionException("Synthetic rejection"); + when(rejected.platform.runAtEntityWithFallback(any(), any(), any())).thenThrow(failure); + assertSame(failure, assertThrows(RejectedExecutionException.class, rejected::submit)); + verifyNoInteractions(rejected.action, rejected.retired, rejected.rejected); + } + + @Test + void nonFoliaKeepsTheEstablishedSchedulerPath() { + Fixture fixture = new Fixture(); + when(fixture.folia.isFolia()).thenReturn(false); + fixture.submit(); + verify(fixture.scheduler).runTask(fixture.plugin, fixture.action, fixture.player); + verifyNoInteractions(fixture.platform, fixture.action, fixture.retired, fixture.rejected); + } + + private static final class Fixture { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final Player player = mock(Player.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final FoliaLib folia = mock(FoliaLib.class); + final PlatformScheduler platform = mock(PlatformScheduler.class); + final Runnable action = mock(Runnable.class); + final Runnable retired = mock(Runnable.class); + final Consumer rejected = mock(); + final CompletableFuture result = new CompletableFuture<>(); + Consumer owner; + Runnable fallback; + + Fixture() { + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.isFolia()).thenReturn(true); + when(folia.getImpl()).thenReturn(platform); + when(folia.getScheduler()).thenReturn(platform); + when(platform.runAtEntityWithFallback(any(), any(), any())).thenAnswer(call -> { + owner = call.getArgument(1); + fallback = call.getArgument(2); + return result; + }); + } + + void submit() { + RewardEligibility.runPlayerTask(plugin, player, action, retired, rejected); + } + + // Emulate a dependency relocation at the scheduler boundary only. Production + // code must handle enum names without casting to a particular vendor enum. + void returnRelocated(CompletableFuture relocated) { + when(platform.runAtEntityWithFallback(any(), any(), any())).thenAnswer(call -> { + owner = call.getArgument(1); + fallback = call.getArgument(2); + return relocated; + }); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java index 74125dbdd..bfb31c11c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RetiringOwnerScheduler.java @@ -14,7 +14,7 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.simpleapi.folialib.FoliaLib; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; -import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.simpleapi.folialib.impl.PlatformScheduler; import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; import com.bencodez.simpleapi.scheduler.BukkitScheduler; @@ -35,10 +35,11 @@ private enum Admission { NORMAL, IMMEDIATE_RETIREMENT, LATER_RETIREMENT, REJECTE this.ownerQueue = ownerQueue; this.retirementQueue = retirementQueue; FoliaLib folia = mock(FoliaLib.class); - ServerImplementation implementation = mock(ServerImplementation.class); + PlatformScheduler implementation = mock(PlatformScheduler.class); when(scheduler.getFoliaLib()).thenReturn(folia); when(folia.isFolia()).thenReturn(true); when(folia.getImpl()).thenReturn(implementation); + when(folia.getScheduler()).thenReturn(implementation); // Exercise the real wrapper that discards runAtEntity's returned future. doCallRealMethod().when(scheduler).runTask(eq(plugin), any(Runnable.class), any(Entity.class)); when(implementation.runAtEntity(any(Entity.class), any())).thenAnswer(call -> diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/packaging/PackagedArtifactTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/packaging/PackagedArtifactTest.java index bb22288a5..d8c1e0d18 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/packaging/PackagedArtifactTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/packaging/PackagedArtifactTest.java @@ -2,15 +2,23 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.ParameterizedType; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; import java.util.jar.JarFile; +import org.bukkit.entity.Entity; + import org.junit.jupiter.api.Test; -/** Verifies the actual minimized artifact rather than Maven's test classpath. */ +/** Verifies the actual shaded artifact rather than Maven's test classpath. */ public class PackagedArtifactTest { @Test @@ -31,6 +39,43 @@ void keepsRequiredClassesAndRelocations() throws Exception { Files.size(artifactPath)); } + @Test + void packagedSchedulerUsesOneRelocatedEnumContract() throws Exception { + Path artifactPath = packagedJar(); + String namespace = "com.bencodez.simpleapi.folialib."; + // Isolate FoliaLib from the dependency JAR on the test classpath. Bukkit + // stays in the parent loader; no server or live scheduler is started. + try (URLClassLoader loader = new URLClassLoader(new URL[] { artifactPath.toUri().toURL() }, + getClass().getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith(namespace)) return super.loadClass(name, resolve); + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) loaded = findClass(name); + if (resolve) resolveClass(loaded); + return loaded; + } + } + }; JarFile artifact = new JarFile(artifactPath.toFile())) { + Class folia = loader.loadClass(namespace + "FoliaLib"); + Class platform = loader.loadClass(namespace + "impl.PlatformScheduler"); + Class status = loader.loadClass(namespace + "enums.EntityTaskResult"); + assertEquals(loader, folia.getClassLoader()); + assertEquals(platform, folia.getMethod("getScheduler").getReturnType()); + assertEquals(platform, folia.getMethod("getImpl").getReturnType()); + var admission = platform.getMethod("runAtEntityWithFallback", Entity.class, + java.util.function.Consumer.class, Runnable.class); + ParameterizedType result = (ParameterizedType) admission.getGenericReturnType(); + assertEquals(java.util.concurrent.CompletableFuture.class, result.getRawType()); + assertEquals(status, result.getActualTypeArguments()[0]); + assertEquals(List.of("SUCCESS", "ENTITY_RETIRED", "SCHEDULER_RETIRED"), + Arrays.stream(status.getEnumConstants()).map(value -> ((Enum) value).name()).toList()); + assertTrue(artifact.stream().noneMatch(entry -> entry.getName().startsWith("com/tcoded/folialib/")), + "Original FoliaLib classes must not coexist with SimpleAPI's relocated classes"); + } + } + private static Path packagedJar() { String configured = System.getProperty("advancedcore.packagedJar"); assertNotNull(configured, "Run this test through the Maven package lifecycle");