diff --git a/build.gradle.kts b/build.gradle.kts index 9db76ad..e2a33ae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,9 +10,10 @@ group = "org.geysermc.globallinkserver" dependencies { paperweight.paperDevBundle("1.21.4-R0.1-SNAPSHOT") - implementation(libs.bundles.fastutil) compileOnly(libs.floodgate.api) implementation(libs.mariadb.client) + implementation(libs.bundles.fastutil) + compileOnly(libs.checker.qual) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 13947e4..56cb898 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,21 @@ [versions] -fastutil = "8.5.2" +floodgate = "2.2.3-SNAPSHOT" mariadb-client = "2.7.3" +fastutil = "8.5.2" checker-qual = "3.21.1" + indra = "3.1.2" paperweight = "2.0.0-beta.12" runpaper = "2.3.1" [libraries] -floodgate-api = { group = "org.geysermc.floodgate", name = "api", version = "2.2.3-SNAPSHOT" } +floodgate-api = { group = "org.geysermc.floodgate", name = "api", version.ref = "floodgate" } +mariadb-client = { module = "org.mariadb.jdbc:mariadb-java-client", version.ref = "mariadb-client" } fastutil-int-int-maps = { group = "com.nukkitx.fastutil", name = "fastutil-int-int-maps", version.ref = "fastutil" } fastutil-int-object-maps = { group = "com.nukkitx.fastutil", name = "fastutil-int-object-maps", version.ref = "fastutil" } fastutil-object-int-maps = { group = "com.nukkitx.fastutil", name = "fastutil-object-int-maps", version.ref = "fastutil" } fastutil-object-object-maps = { group = "com.nukkitx.fastutil", name = "fastutil-object-object-maps", version.ref = "fastutil" } -mariadb-client = { module = "org.mariadb.jdbc:mariadb-java-client", version.ref = "mariadb-client" } checker-qual = { module = "org.checkerframework:checker-qual", version.ref = "checker-qual" } diff --git a/src/main/java/org/geysermc/globallinkserver/Components.java b/src/main/java/org/geysermc/globallinkserver/Components.java index a5bfff4..d515372 100644 --- a/src/main/java/org/geysermc/globallinkserver/Components.java +++ b/src/main/java/org/geysermc/globallinkserver/Components.java @@ -37,8 +37,10 @@ private Components() {} public static final Component LINK_ALREADY_LINKED = Component.text( "You are already linked! You need to unlink first before linking again.", NamedTextColor.RED); public static final Component LINK_CODE_INVALID_RANGE = Component.text("Invalid link code!", NamedTextColor.RED); + public static final Component LINK_REQUEST_REPLACED = Component.text( + "You already had an active link request, so your old request has been invalidated.", NamedTextColor.AQUA); public static final Component LINK_REQUEST_NOT_FOUND = - Component.text("Could not find the provided link. Is it expired?", NamedTextColor.RED); + Component.text("Could not find the provided link. Has it expired?", NamedTextColor.RED); public static final Component LINK_REQUEST_SAME_PLATFORM = Component.text( "You can only link a Java account to a Bedrock account. ", NamedTextColor.RED) .append(Component.text("Try to start the linking process again!")); diff --git a/src/main/java/org/geysermc/globallinkserver/GlobalLinkServer.java b/src/main/java/org/geysermc/globallinkserver/GlobalLinkServer.java index bafb142..34ffe6a 100644 --- a/src/main/java/org/geysermc/globallinkserver/GlobalLinkServer.java +++ b/src/main/java/org/geysermc/globallinkserver/GlobalLinkServer.java @@ -6,20 +6,15 @@ package org.geysermc.globallinkserver; import com.destroystokyo.paper.event.server.PaperServerListPingEvent; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; -import com.google.common.cache.RemovalCause; import com.mojang.brigadier.arguments.IntegerArgumentType; import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.event.player.AsyncChatEvent; import io.papermc.paper.plugin.lifecycle.event.LifecycleEventManager; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; -import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.GameRule; @@ -34,20 +29,21 @@ import org.bukkit.event.player.PlayerCommandPreprocessEvent; import org.bukkit.event.player.PlayerCommandSendEvent; import org.bukkit.event.player.PlayerInteractEvent; -import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.floodgate.api.FloodgateApi; import org.geysermc.globallinkserver.config.ConfigReader; +import org.geysermc.globallinkserver.handler.CommandHandler; import org.geysermc.globallinkserver.handler.JoinHandler; +import org.geysermc.globallinkserver.handler.MoveInactivityHandler; import org.geysermc.globallinkserver.link.LinkManager; import org.geysermc.globallinkserver.manager.DatabaseManager; import org.geysermc.globallinkserver.manager.PlayerManager; -import org.geysermc.globallinkserver.service.LinkLookupService; import org.geysermc.globallinkserver.service.LinkInfoService; -import org.geysermc.globallinkserver.handler.CommandHandler; +import org.geysermc.globallinkserver.service.LinkLookupService; +import org.geysermc.globallinkserver.util.MultiConditionSet; import org.geysermc.globallinkserver.util.Utils; @SuppressWarnings("UnstableApiUsage") @@ -55,20 +51,15 @@ public class GlobalLinkServer extends JavaPlugin implements Listener { private static final Set PERMITTED_COMMANDS = Set.of("link", "linkaccount", "linkinfo", "info", "unlink", "unlinkaccount", "help"); - private final Cache playerIdleCache = CacheBuilder.newBuilder() - .expireAfterWrite(5, TimeUnit.MINUTES) - .removalListener(notification -> { - if (notification.wasEvicted() && notification.getCause() == RemovalCause.EXPIRED) { - Player player = Bukkit.getPlayer(notification.getKey()); - if (player != null) { - Bukkit.getScheduler().callSyncMethod(this, () -> { - player.kick(Components.KICK_IDLE); - return null; - }); - } - } - }) - .build(); + private final MultiConditionSet playerIdleTracker = new MultiConditionSet<>(15_000, uuid -> { + Player player = Bukkit.getPlayer(uuid); + if (player != null) { + Bukkit.getScheduler().callSyncMethod(this, () -> { + player.kick(Components.KICK_IDLE); + return null; + }); + } + }); private LinkLookupService linkLookupService; private LinkInfoService linkInfoService; @@ -85,28 +76,18 @@ public void onEnable() { var commandUtils = new CommandHandler(linkLookupService, linkInfoService, linkManager, playerManager, this); - Bukkit.getScheduler().scheduleSyncRepeatingTask(this, linkManager::cleanupTempLinks, 0, 1); - - Bukkit.getScheduler() - .scheduleSyncRepeatingTask( - this, - () -> { - Bukkit.getOnlinePlayers().forEach(player -> { - if (linkLookupService.isLookupCompleted(player)) { - if (linkLookupService.isLinkedCached(player)) { - player.sendActionBar(Components.UNLINK_INSTRUCTION); - } else { - player.sendActionBar(Components.LINK_INSTRUCTION); - } - } - }); - }, - 10, - 15); + // clean up link requests every 30s + Bukkit.getScheduler().scheduleSyncRepeatingTask(this, linkManager::cleanupLinkRequests, 60 * 20, 30 * 20); + + Bukkit.getScheduler().scheduleSyncRepeatingTask(this, this::broadcastLinkStatusActionbar, 10, 15); var pluginManager = getServer().getPluginManager(); pluginManager.registerEvents(this, this); - pluginManager.registerEvents(new JoinHandler(linkLookupService, playerIdleCache, this), this); + pluginManager.registerEvents(new JoinHandler(linkLookupService, playerIdleTracker, this), this); + pluginManager.registerEvents(new MoveInactivityHandler(playerIdleTracker), this); + + // if the player has an active link request, don't kick the player + playerIdleTracker.addRemovalCondition(uuid -> !linkManager.hasActiveLinkRequest(uuid)); LifecycleEventManager<@NonNull Plugin> manager = this.getLifecycleManager(); manager.registerEventHandler(LifecycleEvents.COMMANDS, event -> { @@ -159,12 +140,26 @@ public void onEnable() { getServer().clearRecipes(); getServer().setDefaultGameMode(GameMode.ADVENTURE); - // Clean up every 10 seconds - Bukkit.getScheduler().runTaskTimer(this, playerIdleCache::cleanUp, 0, 10 * 20); - getLogger().info("Started Global Linking plugin!"); } + private void broadcastLinkStatusActionbar() { + Bukkit.getOnlinePlayers().forEach(player -> { + if (linkLookupService.isLookupCompleted(player)) { + if (linkLookupService.isLinkedCached(player)) { + player.sendActionBar(Components.UNLINK_INSTRUCTION); + } else { + player.sendActionBar(Components.LINK_INSTRUCTION); + } + } + }); + } + + @Override + public void onDisable() { + playerIdleTracker.close(); + } + @EventHandler public void onCommands(PlayerCommandSendEvent event) { if (event.getPlayer().isOp()) { @@ -219,7 +214,7 @@ public void preCommand(PlayerCommandPreprocessEvent event) { @EventHandler public void onPlayerLeave(PlayerQuitEvent event) { event.quitMessage(null); - playerIdleCache.invalidate(event.getPlayer().getUniqueId()); + playerIdleTracker.remove(event.getPlayer().getUniqueId()); linkLookupService.invalidate(event.getPlayer()); } @@ -227,7 +222,6 @@ public void onPlayerLeave(PlayerQuitEvent event) { public void onEntityDamage(EntityDamageEvent event) { if (event.getCause() == EntityDamageEvent.DamageCause.VOID && event.getEntity() instanceof Player player) { event.setCancelled(true); - Utils.fakeRespawn(player); } } @@ -263,14 +257,4 @@ public void onServerListPing(PaperServerListPingEvent event) { event.setNumPlayers(0); event.setMaxPlayers(1); } - - @EventHandler - public void onPlayerMove(PlayerMoveEvent event) { - // todo player idle should not only depend on movement, also check for pending links - int diffX = event.getFrom().getBlockX() - event.getTo().getBlockX(); - int diffY = event.getFrom().getBlockZ() - event.getTo().getBlockZ(); - if (Math.abs(diffX) > 0 || Math.abs(diffY) > 0) { - playerIdleCache.put(event.getPlayer().getUniqueId(), Instant.now()); - } - } } diff --git a/src/main/java/org/geysermc/globallinkserver/handler/CommandHandler.java b/src/main/java/org/geysermc/globallinkserver/handler/CommandHandler.java index 8a3b24c..3bf9317 100644 --- a/src/main/java/org/geysermc/globallinkserver/handler/CommandHandler.java +++ b/src/main/java/org/geysermc/globallinkserver/handler/CommandHandler.java @@ -50,8 +50,9 @@ public int startLink(CommandContext ctx) { return Command.SINGLE_SUCCESS; } - //todo use the boolean return and send a message that the active link request has been invalidated - linkManager.removeActiveLinkRequest(player); + if (linkManager.removeActiveLinkRequest(player)) { + player.sendMessage(Components.LINK_REQUEST_REPLACED); + } String code = String.format("%04d", linkManager.createTempLink(player)); String otherPlatform = playerManager.isBedrockPlayer(player) ? "Java" : "Bedrock"; diff --git a/src/main/java/org/geysermc/globallinkserver/handler/JoinHandler.java b/src/main/java/org/geysermc/globallinkserver/handler/JoinHandler.java index 07fd4a7..e7bb354 100644 --- a/src/main/java/org/geysermc/globallinkserver/handler/JoinHandler.java +++ b/src/main/java/org/geysermc/globallinkserver/handler/JoinHandler.java @@ -5,8 +5,6 @@ */ package org.geysermc.globallinkserver.handler; -import com.google.common.cache.Cache; -import java.time.Instant; import java.util.UUID; import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; @@ -15,17 +13,18 @@ import org.bukkit.plugin.Plugin; import org.geysermc.globallinkserver.Components; import org.geysermc.globallinkserver.service.LinkLookupService; +import org.geysermc.globallinkserver.util.MultiConditionSet; import org.jspecify.annotations.NullMarked; @NullMarked public final class JoinHandler implements Listener { private final LinkLookupService linkLookupService; - private final Cache playerIdleCache; + private final MultiConditionSet playerIdleTracker; private final Plugin plugin; - public JoinHandler(LinkLookupService linkLookupService, Cache playerIdleCache, Plugin plugin) { + public JoinHandler(LinkLookupService linkLookupService, MultiConditionSet playerIdleTracker, Plugin plugin) { this.linkLookupService = linkLookupService; - this.playerIdleCache = playerIdleCache; + this.playerIdleTracker = playerIdleTracker; this.plugin = plugin; } @@ -43,7 +42,7 @@ public void onPlayerLoad(PlayerJoinEvent event) { otherPlayer.hidePlayer(plugin, player); }); - playerIdleCache.put(player.getUniqueId(), Instant.now()); + playerIdleTracker.add(player.getUniqueId()); linkLookupService.lookup(player).whenComplete(($, throwable) -> { if (throwable != null) { diff --git a/src/main/java/org/geysermc/globallinkserver/handler/MoveInactivityHandler.java b/src/main/java/org/geysermc/globallinkserver/handler/MoveInactivityHandler.java new file mode 100644 index 0000000..490002b --- /dev/null +++ b/src/main/java/org/geysermc/globallinkserver/handler/MoveInactivityHandler.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 GeyserMC + * Licensed under the MIT license + * @link https://github.com/GeyserMC/GlobalLinkServer + */ +package org.geysermc.globallinkserver.handler; + +import it.unimi.dsi.fastutil.objects.Object2LongMap; +import it.unimi.dsi.fastutil.objects.Object2LongMaps; +import it.unimi.dsi.fastutil.objects.Object2LongOpenHashMap; +import java.util.UUID; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.geysermc.globallinkserver.util.MultiConditionSet; + +public final class MoveInactivityHandler implements Listener { + private static final long TIME_TILL_IDLE_MILLIS = 15 * 60 * 1000; // 15 minutes + + private final Object2LongMap lastMoveAction = Object2LongMaps.synchronize(new Object2LongOpenHashMap<>()); + + public MoveInactivityHandler(MultiConditionSet playerIdleTracker) { + playerIdleTracker + .addRemovalCondition(key -> { + long lastMovement = lastMoveAction.getLong(key); + // if not present, the value will be 0. It should never happen, so if that happens we remove them. + return System.currentTimeMillis() - lastMovement >= TIME_TILL_IDLE_MILLIS; + }) + .addRemovalListener(lastMoveAction::removeLong); + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + // just to make sure that there aren't any weird edge cases of a player not firing a PlayerMoveEvent immediately + lastMoveAction.put(event.getPlayer().getUniqueId(), System.currentTimeMillis()); + } + + @EventHandler + public void onPlayerMove(PlayerMoveEvent event) { + int diffX = event.getFrom().getBlockX() - event.getTo().getBlockX(); + int diffY = event.getFrom().getBlockZ() - event.getTo().getBlockZ(); + if (Math.abs(diffX) > 0 || Math.abs(diffY) > 0) { + lastMoveAction.put(event.getPlayer().getUniqueId(), System.currentTimeMillis()); + } + } +} diff --git a/src/main/java/org/geysermc/globallinkserver/link/LinkManager.java b/src/main/java/org/geysermc/globallinkserver/link/LinkManager.java index e343969..8017799 100644 --- a/src/main/java/org/geysermc/globallinkserver/link/LinkManager.java +++ b/src/main/java/org/geysermc/globallinkserver/link/LinkManager.java @@ -26,7 +26,7 @@ @NullMarked public class LinkManager { - private static final int PENDING_LINK_DURATION = 60_000 * 15; // 15 min + private static final int PENDING_LINK_TTL_MILLIS = 15 * 60 * 1000; // 15 min private final PlayerManager playerManager; private final DatabaseManager database; @@ -46,7 +46,7 @@ public LinkManager(PlayerManager playerManager, DatabaseManager database) { } public int createTempLink(Player player) { - var linkRequest = new LinkRequest(createCode(), PENDING_LINK_DURATION, player); + var linkRequest = new LinkRequest(createCode(), PENDING_LINK_TTL_MILLIS, player); linkRequests.put(linkRequest.code(), linkRequest); linkRequestForPlayer.put(player.getUniqueId(), linkRequest.code()); @@ -73,15 +73,25 @@ private int createCode() { private boolean isLinkValid(@Nullable LinkRequest link) { long currentMillis = System.currentTimeMillis(); - return link != null && currentMillis - link.expiryTime() < PENDING_LINK_DURATION; + return link != null && currentMillis < link.expiryTime(); } public boolean removeActiveLinkRequest(Player player) { - int linkId = linkRequestForPlayer.removeInt(player.getUniqueId()); - if (linkId != -1) { - linkRequests.remove(linkId); + int code = linkRequestForPlayer.removeInt(player.getUniqueId()); + if (code != -1) { + linkRequests.remove(code); } - return linkId != -1; + return code != -1; + } + + public boolean hasActiveLinkRequest(UUID uuid) { + int code = linkRequestForPlayer.getInt(uuid); + if (code == -1) { + return false; + } + var request = linkRequests.get(code); + //noinspection ConstantValue ?? + return request != null && isLinkValid(request); } public CompletableFuture finaliseLink(Link linkRequest) { @@ -128,7 +138,7 @@ public CompletableFuture unlinkAccount(Player player) { database.executor()); } - public void cleanupTempLinks() { + public void cleanupLinkRequests() { Iterator iterator = linkRequests.values().iterator(); long ctm = System.currentTimeMillis(); diff --git a/src/main/java/org/geysermc/globallinkserver/util/MultiConditionSet.java b/src/main/java/org/geysermc/globallinkserver/util/MultiConditionSet.java new file mode 100644 index 0000000..53f7819 --- /dev/null +++ b/src/main/java/org/geysermc/globallinkserver/util/MultiConditionSet.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 GeyserMC + * Licensed under the MIT license + * @link https://github.com/GeyserMC/GlobalLinkServer + */ +package org.geysermc.globallinkserver.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.jspecify.annotations.NullMarked; + +/** + * A special list that checks every interval if its keys can be removed. + * A key can only be removed if all provided conditions are satisfied. + * Given an instance with three conditions, if the first condition fails for the given key the remaining conditions won't be checked for the key. + *

+ * Every condition and the removal listeners are expected to be quick functions, as they block the removal check and thus the (potential) removal of the other keys. + * In the case of {@link #remove(Object)} it blocks the caller thread instead + *

+ * Make sure that the conditions and the removal listeners are thread safe (and the code its calling), because the executing thread is undefined. + */ +@NullMarked +public final class MultiConditionSet { + private final List> conditions = new ArrayList<>(); + private final Set keys = Collections.synchronizedSet(new HashSet<>()); + private final List> removalListeners = new ArrayList<>(); + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + + public MultiConditionSet(int checkIntervalInMillis, Consumer removalListener) { + this.removalListeners.add(removalListener); + this.executor.scheduleAtFixedRate(this::checkConditions, 0, checkIntervalInMillis, TimeUnit.MILLISECONDS); + } + + private void checkConditions() { + // should be fine to have everything in a synchronized block, since the concurrent access will be really limited + // in a setting like this link server. + synchronized (keys) { + var iterator = keys.iterator(); + + keys: + while (iterator.hasNext()) { + var key = iterator.next(); + for (Object2BooleanFunction condition : conditions) { + if (!condition.apply(key)) { + continue keys; + } + } + // all conditions have been met, remove the key + iterator.remove(); + for (Consumer consumer : removalListeners) { + consumer.accept(key); + } + } + } + } + + public MultiConditionSet addRemovalCondition(Object2BooleanFunction condition) { + conditions.add(condition); + return this; + } + + public MultiConditionSet addRemovalListener(Consumer removalListener) { + removalListeners.add(removalListener); + return this; + } + + public void add(K key) { + keys.add(key); + } + + public void remove(K key) { + keys.remove(key); + for (Consumer consumer : removalListeners) { + consumer.accept(key); + } + } + + public void close() { + executor.shutdown(); + } +} diff --git a/src/main/java/org/geysermc/globallinkserver/util/Object2BooleanFunction.java b/src/main/java/org/geysermc/globallinkserver/util/Object2BooleanFunction.java new file mode 100644 index 0000000..86bb0d9 --- /dev/null +++ b/src/main/java/org/geysermc/globallinkserver/util/Object2BooleanFunction.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 GeyserMC + * Licensed under the MIT license + * @link https://github.com/GeyserMC/GlobalLinkServer + */ +package org.geysermc.globallinkserver.util; + +@FunctionalInterface +public interface Object2BooleanFunction { + boolean apply(T t); +}