diff --git a/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java b/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java index 819526372..e5dd6ca3a 100644 --- a/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java +++ b/src/main/java/org/mvplugins/multiverse/core/dynamiclistener/DynamicListenerRegistration.java @@ -22,6 +22,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -55,16 +56,20 @@ public void register(@NotNull DynamicListener listener, @NotNull Plugin plugin) Set listenerMethods = new HashSet<>(); listenerMethods.addAll(List.of(listener.getClass().getMethods())); listenerMethods.addAll(List.of(listener.getClass().getDeclaredMethods())); - listenerMethods.forEach(method -> Try.run(() ->{ - if (method.isAnnotationPresent(EventMethod.class)) { - registerAsEventMethod(listener, plugin, method); - } else if (method.isAnnotationPresent(EventClass.class)) { - registerAsEventClass(listener, plugin, method); - } - }).onFailure(e -> { - Logging.severe("Failed to register event method %s in %s", method.getName(), listener.getClass().getName()); - e.printStackTrace(); - })); + // Keep fixed MONITOR handlers after validation if configured priorities become equal. + listenerMethods.stream() + .sorted(Comparator.comparing(this::getDefaultEventPriority)) + .forEach(method -> Try.run(() -> { + if (method.isAnnotationPresent(EventMethod.class)) { + registerAsEventMethod(listener, plugin, method); + } else if (method.isAnnotationPresent(EventClass.class)) { + registerAsEventClass(listener, plugin, method); + } + }).onFailure(e -> { + Logging.severe("Failed to register event method %s in %s", + method.getName(), listener.getClass().getName()); + e.printStackTrace(); + })); } private void registerAsEventMethod(DynamicListener listener, Plugin plugin, Method method) { diff --git a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java index 6d2582680..6ecc6368d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java +++ b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java @@ -7,7 +7,9 @@ package org.mvplugins.multiverse.core.listeners; +import java.util.Map; import java.util.Objects; +import java.util.WeakHashMap; import com.dumptruckman.minecraft.util.Logging; import io.vavr.control.Option; @@ -72,6 +74,7 @@ final class MVPlayerListener implements CoreListener { private final DimensionFinder dimensionFinder; private final CorePermissionsChecker corePermissionsChecker; private final AsyncSafetyTeleporter asyncSafetyTeleporter; + private final Map pendingEntryFees = new WeakHashMap<>(); @Inject MVPlayerListener( @@ -333,7 +336,8 @@ void playerTeleport(PlayerTeleportEvent event) { ResultChain entryResult = worldEntryCheckerProvider.forSender(finalTeleporter).canEnterWorld(fromWorld, toWorld) .onSuccessReason(EntryFeeResult.Success.class, reason -> { if (reason == EntryFeeResult.Success.ENOUGH_MONEY) { - economist.payEntryFee((Player) finalTeleporter, toWorld); + pendingEntryFees.put(event, new EntryFeePayment((Player) finalTeleporter, + toLocation.getWorld(), toWorld.getPrice(), toWorld.getCurrency())); } }) .onFailure(results -> { @@ -344,6 +348,17 @@ void playerTeleport(PlayerTeleportEvent event) { Logging.fine("Teleport result: %s", entryResult); } + @EventMethod + @DefaultEventPriority(EventPriority.MONITOR) + void collectEntryFee(PlayerTeleportEvent event) { + // Remove cancelled payments too, without retaining the player or world after the event. + EntryFeePayment payment = pendingEntryFees.remove(event); + if (payment != null && !event.isCancelled() && event.getTo() != null + && payment.destination().equals(event.getTo().getWorld())) { + economist.payEntryFee(payment.payer(), payment.price(), payment.currency()); + } + } + private void stateSuccess(String playerName, String worldName) { Logging.fine("MV-Core is allowing Player '" + playerName + "' to go to '" + worldName + "'."); @@ -448,4 +463,7 @@ private void doGameModeAndFlightEnforcement(Player player, World world) { enforcementHandler.handleFlightEnforcement(player); enforcementHandler.handleGameModeEnforcement(player); } + + private record EntryFeePayment(Player payer, World destination, double price, Material currency) { + } } diff --git a/src/test/java/org/mvplugins/multiverse/core/config/ConfigTeleportInterceptTest.kt b/src/test/java/org/mvplugins/multiverse/core/config/ConfigTeleportInterceptTest.kt index f62f5f9f7..93976808a 100644 --- a/src/test/java/org/mvplugins/multiverse/core/config/ConfigTeleportInterceptTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/config/ConfigTeleportInterceptTest.kt @@ -1,5 +1,18 @@ package org.mvplugins.multiverse.core.config +import org.bukkit.Material +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.HandlerList +import org.mvplugins.multiverse.core.dynamiclistener.DynamicListener +import org.mvplugins.multiverse.core.dynamiclistener.DynamicListenerRegistration +import org.mvplugins.multiverse.core.dynamiclistener.EventPriorityMapper +import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventMethod +import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventPriorityKey +import org.mvplugins.multiverse.core.dynamiclistener.annotations.DefaultEventPriority +import org.bukkit.event.player.PlayerTeleportEvent +import org.bukkit.inventory.ItemStack +import org.mvplugins.multiverse.core.teleportation.TeleportQueue import org.bukkit.Location import org.mockbukkit.mockbukkit.entity.PlayerMock import org.mvplugins.multiverse.core.TestWithMockBukkit @@ -54,4 +67,202 @@ class ConfigTeleportInterceptTest : TestWithMockBukkit() { .onSuccess (Runnable { throw AssertionError("Teleport should have failed") }) .onFailure(Runnable { assertEquals("world", player.world.name) }) } + + private fun configureEntryFee(price: Double = 2.0) { + config.teleportIntercept = true + config.enforceAccess = false + val worldManager = serviceLocator.getActiveService(WorldManager::class.java)!! + val world = worldManager.getLoadedWorld("world2").get() + world.setEntryFeeEnabled(true).get() + world.setCurrency(Material.DIAMOND).get() + world.setPrice(price).get() + player.inventory.setItem(0, ItemStack(Material.DIAMOND, 2)) + } + + private fun onTeleport(priority: EventPriority = EventPriority.HIGHEST, action: (PlayerTeleportEvent) -> Unit) { + server.pluginManager.registerEvent(PlayerTeleportEvent::class.java, object : Listener {}, priority, + { _, event -> action(event as PlayerTeleportEvent) }, multiverseCore) + } + + private fun diamonds(payer: PlayerMock = player): Int = + payer.inventory.all(Material.DIAMOND).values.sumOf { it.amount } + + @Test + fun `Late cancellation does not charge an entry fee`() { + configureEntryFee() + onTeleport { it.isCancelled = true } + assertFalse(player.teleport(location)) + assertEquals("world", player.world.name) + assertEquals(2, diamonds()) + } + + @Test + fun `Successful teleport charges exactly once`() { + configureEntryFee() + var balanceBeforeMonitor = -1 + onTeleport { balanceBeforeMonitor = diamonds() } + assertTrue(player.teleport(location)) + assertEquals(2, balanceBeforeMonitor) + assertEquals("world2", player.world.name) + assertEquals(0, diamonds()) + } + + @Test + fun `Pre-cancelled teleport does not charge`() { + configureEntryFee() + onTeleport(EventPriority.LOWEST) { it.isCancelled = true } + assertFalse(player.teleport(location)) + assertEquals(2, diamonds()) + } + + @Test + fun `Insufficient funds still cancels before monitor`() { + configureEntryFee() + player.inventory.setItem(0, ItemStack(Material.DIAMOND, 1)) + assertFalse(player.teleport(location)) + assertEquals("world", player.world.name) + assertEquals(1, diamonds()) + } + + @Test + fun `Same-world teleport remains free`() { + configureEntryFee() + assertTrue(player.teleport(Location(player.world, 5.0, 5.0, 5.0))) + assertEquals(2, diamonds()) + } + + @Test + fun `Late cancellation does not grant a negative entry fee`() { + configureEntryFee(-2.0) + onTeleport { it.isCancelled = true } + assertFalse(player.teleport(location)) + assertEquals(2, diamonds()) + } + + @Test + fun `Successful teleport retains negative entry fee rewards`() { + configureEntryFee(-2.0) + assertTrue(player.teleport(location)) + assertEquals(4, diamonds()) + } + + @Test + fun `Queued teleporter pays instead of the teleported player`() { + configureEntryFee() + val payer = server.addPlayer() + payer.inventory.setItem(0, ItemStack(Material.DIAMOND, 2)) + serviceLocator.getActiveService(TeleportQueue::class.java)!!.addToQueue(payer, player) + assertTrue(player.teleport(location)) + assertEquals(0, diamonds(payer)) + assertEquals(2, diamonds()) + } + + @Test + fun `Redirecting the destination does not charge for the original world`() { + configureEntryFee() + onTeleport { it.to = Location(player.world, 5.0, 5.0, 5.0) } + assertTrue(player.teleport(location)) + assertEquals("world", player.world.name) + assertEquals(2, diamonds()) + } + + @Test + fun `World access denial remains enforced before payment`() { + configureEntryFee() + config.enforceAccess = true + assertFalse(player.teleport(location)) + assertEquals("world", player.world.name) + assertEquals(2, diamonds()) + } + + @Test + fun `Cancelled payment does not leak into the next teleport`() { + configureEntryFee() + var cancel = true + onTeleport { it.isCancelled = cancel } + assertFalse(player.teleport(location)) + assertEquals(2, diamonds()) + cancel = false + assertTrue(player.teleport(location)) + assertEquals(0, diamonds()) + } + + private fun setPermissionCheckPriority(priority: EventPriority) { + val listener = PlayerTeleportEvent.getHandlerList().registeredListeners + .first { it.listener.javaClass.simpleName == "MVPlayerListener" }.listener as DynamicListener + HandlerList.unregisterAll(listener) + serviceLocator.getActiveService(EventPriorityMapper::class.java)!! + .setPriority("mvcore-player-teleport", priority) + serviceLocator.getActiveService(DynamicListenerRegistration::class.java)!!.register(listener, multiverseCore) + } + + @Test + fun `Custom permission-check priority still defers payment until monitor`() { + configureEntryFee() + setPermissionCheckPriority(EventPriority.LOW) + onTeleport(EventPriority.NORMAL) { it.isCancelled = true } + assertFalse(player.teleport(location)) + assertEquals(2, diamonds()) + } + + @Test + fun `Every configurable permission-check priority collects exactly one fee`() { + configureEntryFee() + var cancel = true + onTeleport { it.isCancelled = cancel } + for (priority in EventPriority.entries) { + setPermissionCheckPriority(priority) + player.inventory.setItem(0, ItemStack(Material.DIAMOND, 2)) + cancel = true + assertFalse(player.teleport(location), priority.name) + assertEquals(2, diamonds(), priority.name) + cancel = false + assertTrue(player.teleport(location), priority.name) + assertEquals(0, diamonds(), priority.name) + assertTrue(player.teleport(Location(server.getWorld("world"), 0.0, 5.0, 0.0))) + } + } + + class OrderedListener : DynamicListener { + val calls = mutableListOf() + + @EventMethod + @EventPriorityKey("test-fee-validation") + @DefaultEventPriority(EventPriority.HIGHEST) + fun validate(event: PlayerTeleportEvent) { + calls.add("validate") + } + + @EventMethod + @DefaultEventPriority(EventPriority.MONITOR) + fun collect(event: PlayerTeleportEvent) { + calls.add("collect") + } + } + + @Test + fun `Default monitor handler remains after validation when configured priorities tie`() { + val listener = OrderedListener() + serviceLocator.getActiveService(EventPriorityMapper::class.java)!! + .setPriority("test-fee-validation", EventPriority.MONITOR) + serviceLocator.getActiveService(DynamicListenerRegistration::class.java)!!.register(listener, multiverseCore) + server.pluginManager.callEvent(PlayerTeleportEvent(player, player.location, location)) + assertEquals(listOf("validate", "collect"), listener.calls) + } + + @Test + fun `Fee exemption remains free`() { + configureEntryFee() + player.addAttachment(multiverseCore, "multiverse.exempt.world2", true) + assertTrue(player.teleport(location)) + assertEquals(2, diamonds()) + } + + @Test + fun `Console initiated teleport remains free`() { + configureEntryFee() + serviceLocator.getActiveService(TeleportQueue::class.java)!!.addToQueue(server.consoleSender, player) + assertTrue(player.teleport(location)) + assertEquals(2, diamonds()) + } }