diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 05f3eb66fb..c21efc54ce 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -39,6 +39,7 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderRequest; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; +import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardEditData; @@ -96,6 +97,7 @@ import com.bencodez.votingplugin.listeners.VotingPluginUpdateEvent; import com.bencodez.votingplugin.placeholders.MVdWPlaceholders; import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; import com.bencodez.votingplugin.placeholders.VotingPluginExpansion; import com.bencodez.votingplugin.presets.VoteSitePresetSetupHandler; import com.bencodez.votingplugin.proxy.control.HostedControlManager; @@ -231,6 +233,9 @@ public File getLoadedPluginJarFile() { @Getter private PlaceHolders placeholders; + @Getter + private final PlaceholderPlayerPresence placeholderPlayerPresence = new PlaceholderPlayerPresence(); + @Getter private VoteTester voteTester; @@ -650,6 +655,7 @@ public void onPostLoad() { registerCommands(); checkVotifier(); registerEvents(); + refreshPlaceholderPlayerPresence(); loadVoteBroadcast(); @@ -1791,6 +1797,7 @@ public void onFinish() { @Override public void onUnLoad() { + placeholderPlayerPresence.clear(); stopBackendHostedControlLifecycle(); stopBackendControlConnectorLifecycle(); if (getBackendProxyHandler() != null) { @@ -1929,6 +1936,23 @@ public void reloadAll() { reloadPlugin(true, true); } + /** Captures Bukkit presence while the lifecycle caller owns platform access. */ + public void refreshPlaceholderPlayerPresence() { + boolean onlineMode = getOptions().isOnlineMode(); + placeholderPlayerPresence.replace(Bukkit::getOnlinePlayers, + player -> placeholderStorageUuid(player, onlineMode)); + } + + static UUID placeholderStorageUuid(Player player, boolean onlineMode) { + if (onlineMode) return player.getUniqueId(); + String cachedUuid = UuidLookup.getInstance().getCachedUUID(player.getName()); + try { + return UUID.fromString(cachedUuid); + } catch (IllegalArgumentException | NullPointerException ignored) { + return player.getUniqueId(); + } + } + /** Reloads configuration applied by Control before its result is acknowledged. */ public void reloadFromControl() { reloadPlugin(false, false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java index 8a2a216c98..36a0d0f3f0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerJoinEvent.java @@ -1,4 +1,6 @@ -package com.bencodez.votingplugin.listeners; +package com.bencodez.votingplugin.listeners; + +import java.util.UUID; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -8,9 +10,10 @@ import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.user.UserDataFetchMode; -import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent; -import com.bencodez.votingplugin.VotingPluginMain; -import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.user.VotingPluginUser; public class PlayerJoinEvent implements Listener { @@ -26,18 +29,38 @@ public PlayerJoinEvent(VotingPluginMain plugin) { this.plugin = plugin; } - private static boolean isBlank(String s) { - return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim()); - } + /** Capture the entity owner before asynchronous user notifications can need it. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onPlayerJoin(org.bukkit.event.player.PlayerJoinEvent event) { + if (event == null || event.getPlayer() == null) return; + Player player = event.getPlayer(); + plugin.getPlaceholderPlayerPresence().playerOnline(placeholderUuid(player), player); + } + + private UUID placeholderUuid(Player player) { + VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(player); + return user == null || user.getJavaUUID() == null ? player.getUniqueId() : user.getJavaUUID(); + } + + private static boolean isBlank(String s) { + return s == null || s.trim().isEmpty() || "null".equalsIgnoreCase(s.trim()); + } + + private void clearPlaceholderCachesIfOffline(UUID uuid) { + PlaceHolders placeholders = plugin.getPlaceholders(); + if (placeholders != null) { + plugin.getPlaceholderPlayerPresence().runIfOffline(uuid, () -> placeholders.onLogout(uuid)); + } + } /** * On AdvancedCore login event (post-auth / delayed login). * * @param event the event */ - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) - public void onPlayerLogin(AdvancedCoreLoginEvent event) { - if (event == null || !plugin.isMySQLOkay() || event.isCancelled() || event.getUser() == null) { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onPlayerLogin(AdvancedCoreLoginEvent event) { + if (event == null || !plugin.isMySQLOkay() || event.isCancelled() || event.getUser() == null) { return; } @@ -57,9 +80,12 @@ public void onPlayerLogin(AdvancedCoreLoginEvent event) { return; } - Player player = event.getPlayer(); - - if (player != null && player.isOp() && plugin.isYmlError()) { + Player player = event.getPlayer(); + if (player != null) { + plugin.getPlaceholderPlayerPresence().playerOnline(user.getJavaUUID(), player); + } + + if (player != null && player.isOp() && plugin.isYmlError()) { user.sendMessage("&cVotingPlugin: Detected yml error, please check console for details"); } @@ -93,7 +119,11 @@ public void onPlayerQuit(PlayerQuitEvent event) { final Player player = event.getPlayer(); if (player == null) { return; - } + } + UUID placeholderUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player); + if (placeholderUuid == null) placeholderUuid = placeholderUuid(player); + boolean retiredPresence = plugin.getPlaceholderPlayerPresence().playerOffline(placeholderUuid, player); + if (retiredPresence) clearPlaceholderCachesIfOffline(placeholderUuid); if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.getBackendProxyHandler().playerOffline(player.getName()); @@ -117,10 +147,10 @@ public void run() { return; } - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.logoutRewards(); - plugin.getPlaceholders().onLogout(user); - } - }); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.logoutRewards(); + clearPlaceholderCachesIfOffline(user.getJavaUUID()); + } + }); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index 04e53559c3..3fadea0c42 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -6,14 +6,19 @@ import java.time.temporal.TemporalField; import java.time.temporal.WeekFields; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; import java.util.Locale; import java.util.Map.Entry; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicLong; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; @@ -30,8 +35,9 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.specialrewards.votemilestones.VoteMilestone; import com.bencodez.votingplugin.topvoter.TopVoter; -import com.bencodez.votingplugin.topvoter.TopVoterPlayer; -import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.topvoter.TopVoterPlayer; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.votesites.VoteSite; import lombok.Getter; @@ -49,6 +55,18 @@ public class PlaceHolders { private ArrayList cachedPlaceholders = new ArrayList<>(); private ConcurrentLinkedQueue placeholdersToSetCacheOn = new ConcurrentLinkedQueue<>(); + private final Set> platformOwnedPlaceholders = + Collections.newSetFromMap(new IdentityHashMap<>()); + private final Set> offlineWorkerPlaceholders = + Collections.newSetFromMap(new IdentityHashMap<>()); + private record PlaceholderClassification(List> all, + Set> platform, Set> offlineWorker) { } + private volatile PlaceholderClassification userDataChangeClassification = + new PlaceholderClassification(List.of(), Set.of(), Set.of()); + private final AtomicLong platformUpdateSequence = new AtomicLong(); + private record PlatformUpdateKey(UUID uuid, PlaceHolder placeholder) { } + private final ConcurrentHashMap platformUpdateGenerations = new ConcurrentHashMap<>(); + private boolean userDataChangeListenerRegistered; @Getter private PlaceholderCacheLevel cacheLevel; @@ -187,7 +205,7 @@ public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean ja return "no player"; } - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(p); + VotingPluginUser user = resolvePlaceholderUser(p); if (useCache) { if (!cachedPlaceholders.contains(identifier)) { // not cached placeholder @@ -299,8 +317,10 @@ public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean ja * Load all placeholders. */ public void load() { - placeholders.clear(); - nonPlayerPlaceholders.clear(); + placeholders.clear(); + nonPlayerPlaceholders.clear(); + platformOwnedPlaceholders.clear(); + offlineWorkerPlaceholders.clear(); // older placeholders, might be removed in the future placeholders.add(new PlaceHolder("total") { @@ -493,13 +513,13 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { } }.withDescription("Return true/false if player can vote on all sites").updateDataKey("LastVotes")); - placeholders.add(new PlaceHolder("CanVoteSites") { + placeholders.add(platformOwnedWithOfflineWorkerFallback(new PlaceHolder("CanVoteSites") { @Override public String placeholderRequest(VotingPluginUser user, String identifier) { return "" + user.getSitesNotVotedOn(); } - }.withDescription("Return number of votesites available").updateDataKey("LastVotes")); + }.withDescription("Return number of votesites available").updateDataKey("LastVotes"))); placeholders.add(new PlaceHolder("Next_AnySite") { @@ -530,21 +550,21 @@ public String placeholderRequest(VotingPluginUser user, String identifier) { } }.withDescription("How long until user can vote on anysite").updateDataKey("LastVotes")); - placeholders.add(new PlaceHolder("SitesAvailable") { + placeholders.add(platformOwnedWithOfflineWorkerFallback(new PlaceHolder("SitesAvailable") { @Override public String placeholderRequest(VotingPluginUser user, String identifier) { return "" + user.getSitesNotVotedOn(); } - }.withDescription("Get number of sites available to be voted on").updateDataKey("LastVotes")); + }.withDescription("Get number of sites available to be voted on").updateDataKey("LastVotes"))); - placeholders.add(new PlaceHolder("SitesAvailableTotal") { - - @Override - public String placeholderRequest(VotingPluginUser user, String identifier) { - return "" + user.getTotalNumberOfSites(); - } - }.withDescription("Get total number of sites available to be voted on")); + placeholders.add(platformOwnedWithOfflineWorkerFallback(new PlaceHolder("SitesAvailableTotal") { + + @Override + public String placeholderRequest(VotingPluginUser user, String identifier) { + return "" + user.getTotalNumberOfSites(); + } + }.withDescription("Get total number of sites available to be voted on"))); for (final VoteSite voteSite : plugin.getVoteSiteManager().getVoteSitesEnabled()) { placeholders.add(new CalculatingPlaceholder("Next_" + voteSite.getKey()) { @@ -1174,55 +1194,270 @@ public String placeholderRequest(String identifier) { } } - plugin.getUserManager().getUserDataChange().add(new UserDataChanged() { + publishUserDataChangePlaceholders(); + if (!userDataChangeListenerRegistered) { + plugin.getUserManager().getUserDataChange().add(new UserDataChanged() { - @Override - public void onChange(AdvancedCoreUser user, String... keys) { - VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - if (!vpUser.isCached()) { - vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE); + @Override + public void onChange(AdvancedCoreUser user, String... keys) { + onUserDataChange(user, keys); } - for (PlaceHolder placeholder : placeholders) { - if (placeholder.isUsesCache()) { - if (placeholder.isCached(placeholder.getIdentifier(), user.getJavaUUID()) - || !getCacheLevel().onlineOnly() || user.isOnline()) { - if (placeholder instanceof CalculatingPlaceholder) { - CalculatingPlaceholder cPlaceholder = (CalculatingPlaceholder) placeholder; - if (placeholder.isUsesCache()) { - for (String key : keys) { - if (placeholder.getUpdateDataKey().equalsIgnoreCase(key)) { - for (String ident : placeholder.getCache().keySet()) { - String value = placeholder.placeholderRequest(vpUser, ident); - cPlaceholder.getCacheData().put(user.getJavaUUID(), - cPlaceholder.placeholderDataRequest(vpUser, ident)); - placeholder.getCache().get(ident).put(vpUser.getJavaUUID(), value); - plugin.devDebug("Updated calculating placeholder cache for " - + vpUser.getUUID() + " on " + key + " with " + value); - } - } - } - } - } else { - if (placeholder.isUsesCache()) { - for (String key : keys) { - if (placeholder.getUpdateDataKey().equalsIgnoreCase(key)) { - for (String ident : placeholder.getCache().keySet()) { - String value = placeholder.placeholderRequest(vpUser, ident); - placeholder.getCache().get(ident).put(vpUser.getJavaUUID(), value); - plugin.devDebug("Updated placeholder cache for " + vpUser.getUUID() - + " on " + key + " with " + value); - - } - - } - } - } - } - } + }); + userDataChangeListenerRegistered = true; + } + } + + PlaceHolder platformOwned(PlaceHolder placeholder) { + platformOwnedPlaceholders.add(placeholder); + return placeholder; + } + + PlaceHolder platformOwnedWithOfflineWorkerFallback( + PlaceHolder placeholder) { + offlineWorkerPlaceholders.add(placeholder); + return platformOwned(placeholder); + } + + void publishUserDataChangePlaceholders() { + userDataChangeClassification = new PlaceholderClassification(List.copyOf(placeholders), + Set.copyOf(platformOwnedPlaceholders), Set.copyOf(offlineWorkerPlaceholders)); + } + + void onUserDataChange(AdvancedCoreUser user, String... keys) { + if (user == null || keys == null) return; + VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); + if (vpUser == null) return; + if (!vpUser.isCached()) vpUser.userDataFetechMode(UserDataFetchMode.NO_CACHE); + + UUID uuid = user.getJavaUUID(); + List> platformUpdates = new ArrayList<>(); + List> offlineUpdates = new ArrayList<>(); + PlaceholderClassification classification = userDataChangeClassification; + Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); + for (PlaceHolder placeholder : classification.all()) { + if (!shouldRefresh(placeholder, uuid, keys)) continue; + if (classification.platform().contains(placeholder)) { + if (owner != null) platformUpdates.add(placeholder); + else if (classification.offlineWorker().contains(placeholder)) offlineUpdates.add(placeholder); + } + else updateCachedPlaceholder(placeholder, vpUser, uuid, keys); + } + + if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, vpUser, offlineUpdates, keys); + if (platformUpdates.isEmpty() || !plugin.isEnabled()) return; + schedulePlatformUpdates(uuid, vpUser, List.copyOf(platformUpdates), keys.clone()); + } + + private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, + List> platformUpdates, String[] keys) { + IdentityHashMap, Long> generations = new IdentityHashMap<>(); + for (PlaceHolder placeholder : platformUpdates) { + long generation = platformUpdateGenerations.compute(new PlatformUpdateKey(uuid, placeholder), + (key, current) -> platformUpdateSequence.incrementAndGet()); + generations.put(placeholder, generation); + } + schedulePlatformUpdates(uuid, user, platformUpdates, keys, + capturePlatformPlaceholderSnapshot(user, platformUpdates), generations); + } + + private void schedulePlatformUpdates(UUID uuid, VotingPluginUser user, + List> platformUpdates, String[] keys, + PlatformPlaceholderSnapshot snapshot, + IdentityHashMap, Long> generations) { + if (!hasCurrentPlatformUpdate(uuid, platformUpdates, generations)) return; + Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); + if (owner == null) { + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys, generations); + return; + } + Runnable update = () -> { + if (!plugin.isEnabled() || !hasCurrentPlatformUpdate(uuid, platformUpdates, generations)) return; + if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != owner) { + schedulePlatformUpdates(uuid, user, platformUpdates, keys, snapshot, generations); + return; + } + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return; + for (PlaceHolder placeholder : platformUpdates) { + if (!isCurrentPlatformUpdate(uuid, placeholder, generations)) continue; + if (!shouldRefresh(placeholder, uuid, keys)) continue; + String identifier = placeholder.getIdentifier(); + if (identifier.equalsIgnoreCase("CanVoteSites") || identifier.equalsIgnoreCase("SitesAvailable")) { + publishCachedValue(placeholder, user, uuid, + Integer.toString(snapshot.sitesAvailable(user)), keys); + } else updateCachedPlaceholder(placeholder, user, uuid, keys); + } + }; + BukkitCompletionScheduler.run(plugin, owner, update, () -> { + if (!plugin.isEnabled() || !hasCurrentPlatformUpdate(uuid, platformUpdates, generations)) return; + plugin.getPlaceholderPlayerPresence().playerOffline(uuid, owner); + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys, generations); + }, () -> { }); + } + + private boolean hasCurrentPlatformUpdate(UUID uuid, List> placeholdersToUpdate, + IdentityHashMap, Long> generations) { + return placeholdersToUpdate.stream().anyMatch(placeholder -> + isCurrentPlatformUpdate(uuid, placeholder, generations)); + } + + private boolean isCurrentPlatformUpdate(UUID uuid, PlaceHolder placeholder, + IdentityHashMap, Long> generations) { + return generations.get(placeholder) != null && generations.get(placeholder).equals( + platformUpdateGenerations.get(new PlatformUpdateKey(uuid, placeholder))); + } + + private PlatformPlaceholderSnapshot capturePlatformPlaceholderSnapshot(VotingPluginUser user, + List> platformUpdates) { + boolean needsVoteEligibility = platformUpdates.stream().map(PlaceHolder::getIdentifier) + .anyMatch(identifier -> identifier.equalsIgnoreCase("CanVoteSites") + || identifier.equalsIgnoreCase("SitesAvailable")); + if (!needsVoteEligibility) return PlatformPlaceholderSnapshot.EMPTY; + List votableSitePermissions = new ArrayList<>(); + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && user.canVoteSite(site)) { + votableSitePermissions.add(site.getPermissionToView()); + } + } + return new PlatformPlaceholderSnapshot(List.copyOf(votableSitePermissions)); + } + + private record PlatformPlaceholderSnapshot(List votableSitePermissions) { + private static final PlatformPlaceholderSnapshot EMPTY = new PlatformPlaceholderSnapshot(List.of()); + + private int sitesAvailable(VotingPluginUser user) { + int amount = 0; + for (String permission : votableSitePermissions) { + if (permission.isEmpty() || user.hasPermission(permission, false)) amount++; + } + return amount; + } + } + + private void dispatchOfflinePlatformUpdates(UUID uuid, + List> platformUpdates, String[] keys) { + dispatchOfflinePlatformUpdates(uuid, platformUpdates, keys, null); + } + + private void dispatchOfflinePlatformUpdates(UUID uuid, + List> platformUpdates, String[] keys, + IdentityHashMap, Long> generations) { + try { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + if (!plugin.isEnabled()) return; + List> currentUpdates = generations == null ? platformUpdates + : platformUpdates.stream() + .filter(placeholder -> isCurrentPlatformUpdate(uuid, placeholder, generations)) + .toList(); + if (currentUpdates.isEmpty()) return; + VotingPluginUser currentUser = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false); + if (currentUser == null) return; + if (plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid) != null) { + schedulePlatformUpdates(uuid, currentUser, currentUpdates, keys); + return; + } + if (getCacheLevel().onlineOnly()) return; + PlaceholderClassification classification = userDataChangeClassification; + List> offlineUpdates = currentUpdates.stream() + .filter(classification.offlineWorker()::contains) + .filter(placeholder -> shouldRefresh(placeholder, uuid, keys)) + .toList(); + if (!offlineUpdates.isEmpty()) updateOfflinePlatformPlaceholders(uuid, currentUser, offlineUpdates, keys); + }); + } catch (RejectedExecutionException rejected) { + plugin.debug(rejected); + } + } + + private void updateOfflinePlatformPlaceholders(UUID uuid, VotingPluginUser user, + List> placeholdersToUpdate, String... keys) { + IdentityHashMap, String> values = new IdentityHashMap<>(); + for (PlaceHolder placeholder : placeholdersToUpdate) { + String value = placeholder.getIdentifier().equalsIgnoreCase("SitesAvailableTotal") + ? Integer.toString(user.getTotalNumberOfSitesWithoutOnlinePermissions()) + : Integer.toString(user.getSitesNotVotedOnWithoutOnlinePermissions()); + values.put(placeholder, value); + } + boolean published = plugin.getPlaceholderPlayerPresence().runIfOffline(uuid, () -> { + for (PlaceHolder placeholder : placeholdersToUpdate) { + for (String ident : Set.copyOf(placeholder.getCache().keySet())) { + ConcurrentHashMap cachedValues = placeholder.getCache().get(ident); + if (cachedValues != null) cachedValues.put(uuid, values.get(placeholder)); + } + } + }); + if (published) { + for (PlaceHolder placeholder : placeholdersToUpdate) { + plugin.devDebug("Updated offline placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + values.get(placeholder)); + } + } else if (plugin.isEnabled()) { + schedulePlatformUpdates(uuid, user, List.copyOf(placeholdersToUpdate), keys.clone()); + } + } + + private boolean shouldRefresh(PlaceHolder placeholder, UUID uuid, String... keys) { + if (!placeholder.isUsesCache() || placeholder.getCache() == null) return false; + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) return false; + for (String key : keys) { + if (key != null && placeholder.getUpdateDataKey().equalsIgnoreCase(key)) return true; + } + return false; + } + + @SuppressWarnings("unchecked") + private void updateCachedPlaceholder(PlaceHolder placeholder, VotingPluginUser user, + UUID uuid, String... keys) { + for (String ident : Set.copyOf(placeholder.getCache().keySet())) { + ConcurrentHashMap values = placeholder.getCache().get(ident); + if (values == null) continue; + if (placeholder instanceof CalculatingPlaceholder) { + CalculatingPlaceholder calculating = + (CalculatingPlaceholder) placeholder; + synchronized (calculating) { + calculating.getCacheData().put(uuid, calculating.placeholderDataRequest(user, ident)); + String value = calculating.placeholderRequest(user, ident); + values.put(uuid, value); + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { + values.remove(uuid); + calculating.getCacheData().remove(uuid); + continue; } + plugin.devDebug("Updated calculating placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + value); } - } - }); + } else { + String value = placeholder.placeholderRequest(user, ident); + values.put(uuid, value); + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { + values.remove(uuid); + continue; + } + plugin.devDebug("Updated placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + value); + } + } + } + + private void publishCachedValue(PlaceHolder placeholder, VotingPluginUser user, UUID uuid, + String value, String... keys) { + for (String ident : Set.copyOf(placeholder.getCache().keySet())) { + ConcurrentHashMap values = placeholder.getCache().get(ident); + if (values == null) continue; + values.put(uuid, value); + if (getCacheLevel().onlineOnly() && !plugin.getPlaceholderPlayerPresence().isOnline(uuid)) { + values.remove(uuid); + continue; + } + plugin.devDebug("Updated placeholder cache for " + user.getUUID() + " on " + + matchingKey(placeholder, keys) + " with " + value); + } + } + + private String matchingKey(PlaceHolder placeholder, String... keys) { + for (String key : keys) { + if (key != null && placeholder.getUpdateDataKey().equalsIgnoreCase(key)) return key; + } + return placeholder.getUpdateDataKey(); } /** @@ -1230,23 +1465,32 @@ public void onChange(AdvancedCoreUser user, String... keys) { * @param user the voting plugin user */ public void onLogout(VotingPluginUser user) { - if (plugin.getPlaceholders().getCacheLevel().onlineOnly()) { - for (PlaceHolder placeholder : placeholders) { - if (placeholder.isUsesCache()) { - placeholder.clearCachePlayer(user.getJavaUUID()); - } - } + if (user != null) onLogout(user.getJavaUUID()); + } + + /** Clears online-only cache entries without resolving user storage. */ + public void onLogout(UUID uuid) { + if (getCacheLevel().onlineOnly()) { + platformUpdateGenerations.keySet().removeIf(key -> key.uuid().equals(uuid)); + PlaceholderClassification classification = userDataChangeClassification; + for (PlaceHolder placeholder : classification.all()) { + if (placeholder.isUsesCache()) { + if (placeholder instanceof CalculatingPlaceholder) { + synchronized (placeholder) { placeholder.clearCachePlayer(uuid); } + } else placeholder.clearCachePlayer(uuid); + } + } } } /** * Update all placeholders. */ - public void onUpdate() { - checkNonCachedPlaceholders(); - for (Player p : Bukkit.getOnlinePlayers()) { - onUpdate(plugin.getVotingPluginUserManager().getVotingPluginUser(p), true); - } + public void onUpdate() { + checkNonCachedPlaceholders(); + for (Player p : Bukkit.getOnlinePlayers()) { + onUpdate(getPresenceUser(p), true); + } /* * for (NonPlayerPlaceHolder placeholder : * nonPlayerPlaceholders) { if (placeholder.isUsesCache()) { for (String ident : @@ -1263,34 +1507,32 @@ public void onUpdate() { * @param user the voting plugin user * @param login whether this is a login update */ - public void onUpdate(VotingPluginUser user, boolean login) { - for (PlaceHolder placeholder : placeholders) { - if (placeholder.isUsesCache()) { - if (placeholder.isCached(placeholder.getIdentifier(), user.getJavaUUID()) || login) { - if (placeholder instanceof CalculatingPlaceholder) { - - CalculatingPlaceholder cPlaceholder = (CalculatingPlaceholder) placeholder; - if (placeholder.isUsesCache()) { - for (String ident : placeholder.getCache().keySet()) { - cPlaceholder.getCacheData().put(user.getJavaUUID(), - cPlaceholder.placeholderDataRequest(user, ident)); - placeholder.getCache().get(ident).put(user.getJavaUUID(), - placeholder.placeholderRequest(user, ident)); - } - - } - } else { - if (placeholder.isUsesCache()) { - for (String ident : placeholder.getCache().keySet()) { - placeholder.getCache().get(ident).put(user.getJavaUUID(), - placeholder.placeholderRequest(user, ident)); - } - } - } - } - } - } - } + public void onUpdate(VotingPluginUser user, boolean login) { + if (user == null) return; + UUID uuid = user.getJavaUUID(); + Player owner = plugin.getPlaceholderPlayerPresence().schedulerOwner(uuid); + PlaceholderClassification classification = userDataChangeClassification; + List> platformUpdates = new ArrayList<>(); + List> offlineUpdates = new ArrayList<>(); + for (PlaceHolder placeholder : classification.all()) { + if (!placeholder.isUsesCache() || placeholder.getCache() == null + || (!placeholder.isCached(placeholder.getIdentifier(), uuid) && !login)) continue; + if (classification.platform().contains(placeholder)) { + if (owner != null) platformUpdates.add(placeholder); + else if (!getCacheLevel().onlineOnly() && classification.offlineWorker().contains(placeholder)) { + offlineUpdates.add(placeholder); + } + } else updateCachedPlaceholder(placeholder, user, uuid, placeholder.getUpdateDataKey()); + } + if (!offlineUpdates.isEmpty()) { + updateOfflinePlatformPlaceholders(uuid, user, offlineUpdates, + offlineUpdates.stream().map(PlaceHolder::getUpdateDataKey).toArray(String[]::new)); + } + if (!platformUpdates.isEmpty() && plugin.isEnabled()) { + schedulePlatformUpdates(uuid, user, List.copyOf(platformUpdates), + platformUpdates.stream().map(PlaceHolder::getUpdateDataKey).toArray(String[]::new)); + } + } /** * Handle vote party update. @@ -1308,15 +1550,35 @@ public void onVotePartyUpdate() { /** * Reload placeholders. */ - public void reload() { + public void reload() { + platformUpdateGenerations.clear(); + plugin.refreshPlaceholderPlayerPresence(); cacheLevel = plugin.getConfigFile().getPlaceholderCacheLevel(); onUpdate(); - if (!cacheLevel.equals(PlaceholderCacheLevel.NONE)) { - for (Player player : Bukkit.getOnlinePlayers()) { - onUpdate(plugin.getVotingPluginUserManager().getVotingPluginUser(player), player.isOnline()); - } - } - } + if (!cacheLevel.equals(PlaceholderCacheLevel.NONE)) { + for (Player player : Bukkit.getOnlinePlayers()) { + onUpdate(getPresenceUser(player), player.isOnline()); + } + } + } + + private VotingPluginUser getPresenceUser(Player player) { + UUID storageUuid = plugin.getPlaceholderPlayerPresence().storageUuid(player); + if (storageUuid != null) { + return plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid); + } + return plugin.getVotingPluginUserManager().getVotingPluginUser(player); + } + + VotingPluginUser resolvePlaceholderUser(OfflinePlayer player) { + if (player instanceof Player onlinePlayer) { + UUID storageUuid = plugin.getPlaceholderPlayerPresence().storageUuid(onlinePlayer); + if (storageUuid != null) { + return plugin.getVotingPluginUserManager().getVotingPluginUser(storageUuid); + } + } + return plugin.getVotingPluginUserManager().getVotingPluginUser(player); + } /** * Schedule placeholder check for user. @@ -1328,7 +1590,7 @@ public void schedulePlaceholderCheck(VotingPluginUser user) { @Override public void run() { checkNonCachedPlaceholders(); - onUpdate(user, user.isOnline()); + onUpdate(user, plugin.getPlaceholderPlayerPresence().isOnline(user.getJavaUUID())); } }); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java new file mode 100644 index 0000000000..703d85acce --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresence.java @@ -0,0 +1,151 @@ +package com.bencodez.votingplugin.placeholders; + +import java.util.Collection; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.bukkit.entity.Player; + +/** + * Immutable snapshots of live Bukkit players captured at player-owned lifecycle + * boundaries. Storage workers may inspect membership and pass the captured owner + * to the Folia-aware scheduler, but must not call methods on the player directly. + */ +public final class PlaceholderPlayerPresence { + private final AtomicReference> onlinePlayers = new AtomicReference<>(Map.of()); + private final Object lifecycleLock = new Object(); + + public boolean isOnline(UUID uuid) { + return uuid != null && onlinePlayers.get().containsKey(uuid); + } + + public Player schedulerOwner(UUID uuid) { + return uuid == null ? null : onlinePlayers.get().get(uuid); + } + + /** Resolve the storage key already published for this exact scheduler owner. */ + public UUID storageUuid(Player player) { + if (player == null) return null; + for (Map.Entry entry : onlinePlayers.get().entrySet()) { + if (entry.getValue() == player) return entry.getKey(); + } + return null; + } + + public void playerOnline(Player player) { + if (player == null) return; + playerOnline(player.getUniqueId(), player); + } + + /** Publish the storage UUID with the captured Bukkit scheduler owner. */ + public void playerOnline(UUID uuid, Player player) { + if (uuid == null || player == null) return; + synchronized (lifecycleLock) { + update(current -> { + Map next = new HashMap<>(current); + next.entrySet().removeIf(entry -> entry.getValue() == player && !entry.getKey().equals(uuid)); + next.put(uuid, player); + return Map.copyOf(next); + }); + } + } + + public void playerOffline(UUID uuid) { + if (uuid == null) return; + synchronized (lifecycleLock) { + update(current -> { + if (!current.containsKey(uuid)) return current; + Map next = new HashMap<>(current); + next.remove(uuid); + return Map.copyOf(next); + }); + } + } + + /** Remove only the retired scheduler owner, preserving a concurrently joined replacement. */ + public boolean playerOffline(UUID uuid, Player expectedOwner) { + if (uuid == null || expectedOwner == null) return false; + synchronized (lifecycleLock) { + Map current = onlinePlayers.get(); + if (current.get(uuid) != expectedOwner) return false; + Map next = new HashMap<>(current); + next.remove(uuid); + onlinePlayers.set(Map.copyOf(next)); + return true; + } + } + + /** Rebuild online owners while retaining storage UUIDs already established by login. */ + public void replace(Collection players) { + replace(players, Player::getUniqueId); + } + + /** Resolve and replace one presence snapshot under the same lifecycle boundary. */ + public void replace(Collection players, Function storageUuidResolver) { + replace(() -> players, storageUuidResolver); + } + + /** Capture, resolve, and replace one presence snapshot under the same lifecycle boundary. */ + public void replace(Supplier> playersSupplier, + Function storageUuidResolver) { + synchronized (lifecycleLock) { + Collection players = playersSupplier == null ? null : playersSupplier.get(); + Map knownStorageUuids = new IdentityHashMap<>(); + Map storageUuidByPlayerUuid = new HashMap<>(); + onlinePlayers.get().forEach((uuid, player) -> { + knownStorageUuids.put(player, uuid); + storageUuidByPlayerUuid.put(player.getUniqueId(), uuid); + }); + Map next = new HashMap<>(); + if (players != null) { + for (Player player : players) { + if (player == null) continue; + UUID uuid = knownStorageUuids.get(player); + UUID playerUuid = player.getUniqueId(); + if (uuid == null) uuid = storageUuidByPlayerUuid.get(playerUuid); + if (uuid == null && storageUuidResolver != null) uuid = storageUuidResolver.apply(player); + next.put(uuid == null ? playerUuid : uuid, player); + } + } + onlinePlayers.set(Map.copyOf(next)); + } + } + + /** Replace presence with storage UUIDs resolved at a platform-owned boundary. */ + public void replace(Map players) { + Map next = new HashMap<>(); + if (players != null) players.forEach((uuid, player) -> { + if (uuid != null && player != null) next.put(uuid, player); + }); + synchronized (lifecycleLock) { onlinePlayers.set(Map.copyOf(next)); } + } + + public void clear() { + synchronized (lifecycleLock) { onlinePlayers.set(Map.of()); } + } + + /** Publish an offline-only result atomically with player lifecycle capture. */ + public boolean runIfOffline(UUID uuid, Runnable publication) { + if (uuid == null || publication == null) return false; + synchronized (lifecycleLock) { + if (onlinePlayers.get().containsKey(uuid)) return false; + publication.run(); + return true; + } + } + + private void update(java.util.function.Function, Map> operation) { + Map current; + Map next; + do { + current = onlinePlayers.get(); + next = operation.apply(current); + if (next == current) return; + } while (!onlinePlayers.compareAndSet(current, next)); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 48aff1f0fe..406df59581 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1560,8 +1560,8 @@ public String getPointsPath() { * * @return the number of sites not voted on */ - public int getSitesNotVotedOn() { - int amount = 0; + public int getSitesNotVotedOn() { + int amount = 0; for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { if (!site.isHidden()) { if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { @@ -1571,20 +1571,46 @@ public int getSitesNotVotedOn() { } } } - return amount; - } + return amount; + } + + /** + * Match the existing offline result without resolving a live Bukkit player. + * Permission-gated sites are excluded because the normal call uses + * {@code hasPermission(permission, false)}, which returns false offline. + */ + public int getSitesNotVotedOnWithoutOnlinePermissions() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && site.getPermissionToView().isEmpty() && canVoteSite(site)) amount++; + } + return amount; + } - public int getTotalNumberOfSites() { - int amount = 0; - for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + public int getTotalNumberOfSites() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { if (!site.isHidden()) { if (site.getPermissionToView().isEmpty() || hasPermission(site.getPermissionToView(), false)) { amount++; } } } - return amount; - } + return amount; + } + + /** + * Match the existing offline total without resolving a live Bukkit player. + * Permission-gated sites are excluded because the normal call uses + * {@code hasPermission(permission, false)}, which returns false offline. + */ + public int getTotalNumberOfSitesWithoutOnlinePermissions() { + int amount = 0; + for (VoteSite site : plugin.getVoteSiteManager().getVoteSitesEnabled()) { + if (!site.isHidden() && site.getPermissionToView().isEmpty()) amount++; + } + return amount; + } public int getSitesVotedOn() { int amount = 0; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java index f5135b76a2..3d806c6948 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -23,25 +23,34 @@ public static void run(VotingPluginMain plugin, Player player, Runnable task) { * work has a durable pre-scheduler claim use this to record a recovery state. */ public static void run(VotingPluginMain plugin, Player player, Runnable task, Runnable rejected) { + run(plugin, player, task, task, rejected); + } + + /** Schedule entity-owned work with a separate global-safe retirement fallback. */ + public static void run(VotingPluginMain plugin, Player player, Runnable entityTask, + Runnable fallbackTask, Runnable rejected) { AtomicBoolean executed = new AtomicBoolean(); - Runnable once = () -> { - if (executed.compareAndSet(false, true)) task.run(); + Runnable entityOnce = () -> { + if (executed.compareAndSet(false, true)) entityTask.run(); + }; + Runnable fallbackOnce = () -> { + if (executed.compareAndSet(false, true)) fallbackTask.run(); }; if (player == null) { - runGlobal(plugin, once, rejected); + runGlobal(plugin, fallbackOnce, rejected); return; } AtomicBoolean fallbackSubmitted = new AtomicBoolean(); Runnable fallback = () -> { - if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, once, rejected); + if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, fallbackOnce, rejected); }; try { if (plugin.getBukkitScheduler().getFoliaLib() == null) { - runLegacyEntity(plugin, player, once, fallback); + runLegacyEntity(plugin, player, entityOnce, fallback); return; } CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl() - .runAtEntityWithFallback(player, ignored -> once.run(), fallback); + .runAtEntityWithFallback(player, ignored -> entityOnce.run(), fallback); result.whenComplete((status, failure) -> { // ENTITY_RETIRED invokes fallback itself. A scheduler that was already // retired returns SCHEDULER_RETIRED without invoking it. @@ -50,7 +59,7 @@ public static void run(VotingPluginMain plugin, Player player, Runnable task, Ru } else if (!executed.get()) { // Compatibility with scheduler adapters that report admission but do // not run the consumer inline with future completion. - runLegacyEntity(plugin, player, once, fallback); + runLegacyEntity(plugin, player, entityOnce, fallback); } }); } catch (RuntimeException schedulingFailure) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java new file mode 100644 index 0000000000..c30acd3e5b --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainPlaceholderPresenceTest.java @@ -0,0 +1,59 @@ +package com.bencodez.votingplugin; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.player.UuidLookup; + +class VotingPluginMainPlaceholderPresenceTest { + @Test + void cachedStorageIdentityWinsDuringColdPresenceRefresh() { + Player player = mock(Player.class); + UuidLookup lookup = mock(UuidLookup.class); + UUID bukkitUuid = UUID.randomUUID(); + UUID storageUuid = UUID.randomUUID(); + when(player.getName()).thenReturn("Player"); + when(player.getUniqueId()).thenReturn(bukkitUuid); + when(lookup.getCachedUUID("Player")).thenReturn(storageUuid.toString()); + + try (var uuidLookup = mockStatic(UuidLookup.class)) { + uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); + assertEquals(storageUuid, VotingPluginMain.placeholderStorageUuid(player, false)); + } + } + + @Test + void bukkitIdentityIsUsedWhenNoStorageIdentityWasCaptured() { + Player player = mock(Player.class); + UuidLookup lookup = mock(UuidLookup.class); + UUID bukkitUuid = UUID.randomUUID(); + when(player.getName()).thenReturn("Player"); + when(player.getUniqueId()).thenReturn(bukkitUuid); + when(lookup.getCachedUUID("Player")).thenReturn(""); + + try (var uuidLookup = mockStatic(UuidLookup.class)) { + uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); + assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, false)); + } + } + + @Test + void onlineModeIgnoresAStaleCachedNameMapping() { + Player player = mock(Player.class); + UuidLookup lookup = mock(UuidLookup.class); + UUID bukkitUuid = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(bukkitUuid); + + try (var uuidLookup = mockStatic(UuidLookup.class)) { + uuidLookup.when(UuidLookup::getInstance).thenReturn(lookup); + assertEquals(bukkitUuid, VotingPluginMain.placeholderStorageUuid(player, true)); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java new file mode 100644 index 0000000000..dac97435e0 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerJoinEventPresenceTest.java @@ -0,0 +1,162 @@ +package com.bencodez.votingplugin.listeners; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; + +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class PlayerJoinEventPresenceTest { + @Test + void advancedCoreLoginRekeysPresenceToTheAuthoritativeStorageUuid() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isMySQLOkay()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + Player player = mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + UUID storageUuid = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(playerUuid); + presence.playerOnline(playerUuid, player); + + UserManager userManager = mock(UserManager.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getJavaUUID()).thenReturn(storageUuid); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(userManager.getVotingPluginUser(storageUuid.toString())).thenReturn(user); + PlaceHolders placeholders = mock(PlaceHolders.class); + when(plugin.getPlaceholders()).thenReturn(placeholders); + doAnswer(call -> { + assertTrue(presence.isOnline(storageUuid)); + assertFalse(presence.isOnline(playerUuid)); + return null; + }).when(placeholders).onUpdate(user, true); + + com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent login = + mock(com.bencodez.advancedcore.listeners.AdvancedCoreLoginEvent.class); + when(login.getUuid()).thenReturn(storageUuid.toString()); + when(login.getPlayer()).thenReturn(player); + when(login.getUser()).thenReturn(mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class)); + + new PlayerJoinEvent(plugin).onPlayerLogin(login); + + assertTrue(presence.isOnline(storageUuid)); + assertFalse(presence.isOnline(playerUuid)); + } + + @Test + void bukkitJoinAndQuitUpdatePresenceBeforeAsyncQuitCleanup() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isEnabled()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + when(bungee.isUseBungeecoord()).thenReturn(false); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); + + UUID uuid = UUID.randomUUID(); + UUID storageUuid = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + UserManager userManager = mock(UserManager.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(userManager.getVotingPluginUser(player)).thenReturn(user); + when(user.getJavaUUID()).thenReturn(storageUuid); + org.bukkit.event.player.PlayerJoinEvent join = mock(org.bukkit.event.player.PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(player); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(player); + PlayerJoinEvent listener = new PlayerJoinEvent(plugin); + + listener.onPlayerJoin(join); + assertTrue(presence.isOnline(storageUuid)); + assertFalse(presence.isOnline(uuid)); + listener.onPlayerQuit(quit); + assertFalse(presence.isOnline(storageUuid), + "quit must publish the storage UUID offline before asynchronous storage cleanup"); + } + + @Test + void staleQuitCannotRetireOrClearAReplacementOwner() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isEnabled()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); + PlaceHolders placeholders = mock(PlaceHolders.class); + when(plugin.getPlaceholders()).thenReturn(placeholders); + UserManager userManager = mock(UserManager.class); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + UUID storageUuid = UUID.randomUUID(); + Player retired = mock(Player.class); + Player replacement = mock(Player.class); + when(retired.getUniqueId()).thenReturn(UUID.randomUUID()); + VotingPluginUser retiredUser = mock(VotingPluginUser.class); + when(retiredUser.getJavaUUID()).thenReturn(storageUuid); + when(userManager.getVotingPluginUser(retired)).thenReturn(retiredUser); + presence.playerOnline(storageUuid, retired); + presence.playerOnline(storageUuid, replacement); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(retired); + + new PlayerJoinEvent(plugin).onPlayerQuit(quit); + + assertTrue(presence.isOnline(storageUuid)); + verify(placeholders, never()).onLogout(storageUuid); + } + + @Test + void reconnectBetweenRetirementAndCleanupPreservesReplacementCaches() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + PlaceholderPlayerPresence presence = mock(PlaceholderPlayerPresence.class); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.isEnabled()).thenReturn(true); + BungeeSettings bungee = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(bungee); + ScheduledExecutorService loginTimer = mock(ScheduledExecutorService.class); + when(plugin.getLoginTimer()).thenReturn(loginTimer); + doAnswer(call -> null).when(loginTimer).execute(any(Runnable.class)); + PlaceHolders placeholders = mock(PlaceHolders.class); + when(plugin.getPlaceholders()).thenReturn(placeholders); + UUID storageUuid = UUID.randomUUID(); + Player retired = mock(Player.class); + when(presence.storageUuid(retired)).thenReturn(storageUuid); + when(presence.playerOffline(storageUuid, retired)).thenReturn(true); + when(presence.runIfOffline(eq(storageUuid), any(Runnable.class))).thenReturn(false); + PlayerQuitEvent quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(retired); + + new PlayerJoinEvent(plugin).onPlayerQuit(quit); + + verify(presence).runIfOffline(eq(storageUuid), any(Runnable.class)); + verify(placeholders, never()).onLogout(storageUuid); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java new file mode 100644 index 0000000000..a2e6f2df09 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceHoldersWorkerSafetyTest.java @@ -0,0 +1,454 @@ +package com.bencodez.votingplugin.placeholders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.api.placeholder.CalculatingPlaceholder; +import com.bencodez.advancedcore.api.placeholder.PlaceHolder; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.user.UserManager; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.bencodez.votingplugin.votesites.VoteSiteManager; + +class PlaceHoldersWorkerSafetyTest { + @Test + void workerSafeUpdateUsesCapturedPresenceWithoutBukkitScheduling() { + Fixture fixture = new Fixture(); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("Points", requests); + fixture.publish(placeholder); + + try (var bukkit = mockStatic(Bukkit.class)) { + fixture.placeholders.onUserDataChange(fixture.advancedUser, "Points"); + assertEquals(0, requests.get(), "offline online-only users must not be refreshed"); + + fixture.presence.playerOnline(fixture.player); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "Points"); + assertEquals(1, requests.get()); + assertEquals("value", placeholder.getCache().get("points").get(fixture.uuid)); + verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.advancedUser, never()).isOnline(); + + fixture.presence.playerOffline(fixture.uuid); + fixture.placeholders.onLogout(fixture.uuid); + assertFalse(placeholder.getCache().get("points").containsKey(fixture.uuid)); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "Points"); + assertEquals(1, requests.get()); + bukkit.verifyNoInteractions(); + } + } + + @Test + void playerDependentUpdateSchedulesOnlyItsEntityOwnedWork() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + assertEquals(0, requests.get()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + task.getValue().run(); + assertEquals(1, requests.get()); + verify(fixture.advancedUser, never()).isOnline(); + } + + @Test + void backgroundWarmupSchedulesPlayerDependentWorkOnTheEntityOwner() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUpdate(fixture.votingUser, true); + + assertEquals(0, requests.get()); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + task.getValue().run(); + assertEquals(1, requests.get()); + } + + @Test + void reloadUsesThePresenceStorageUuidForReplacementPlayerWrappers() { + Fixture fixture = new Fixture(); + Player replacement = mock(Player.class); + UUID bukkitUuid = UUID.randomUUID(); + when(replacement.getUniqueId()).thenReturn(bukkitUuid); + when(replacement.isOnline()).thenReturn(true); + when(fixture.userManager.getVotingPluginUser(fixture.uuid)).thenReturn(fixture.votingUser); + fixture.presence.playerOnline(fixture.uuid, replacement); + AtomicInteger requests = new AtomicInteger(); + fixture.publish(fixture.cachedPlaceholder("Points", requests)); + + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of(replacement)); + fixture.placeholders.reload(); + } + + assertEquals(2, requests.get(), "reload updates both existing warmup passes"); + verify(fixture.userManager, times(2)).getVotingPluginUser(fixture.uuid); + verify(fixture.userManager, never()).getVotingPluginUser(replacement); + } + + @Test + void cacheMissUsesThePresenceStorageUuidForAnOnlinePlayer() { + Fixture fixture = new Fixture(); + Player onlinePlayer = mock(Player.class); + when(onlinePlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(fixture.userManager.getVotingPluginUser(fixture.uuid)).thenReturn(fixture.votingUser); + fixture.presence.playerOnline(fixture.uuid, onlinePlayer); + + assertEquals(fixture.votingUser, fixture.placeholders.resolvePlaceholderUser(onlinePlayer)); + + verify(fixture.userManager).getVotingPluginUser(fixture.uuid); + verify(fixture.userManager, never()).getVotingPluginUser(onlinePlayer); + } + + @Test + void backgroundWarmupCapturesVoteEligibilityBeforeTemporaryDataIsCleared() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + VoteSite site = mock(VoteSite.class); + when(site.isHidden()).thenReturn(false); + when(site.getPermissionToView()).thenReturn(""); + when(fixture.voteSiteManager.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site))); + when(fixture.votingUser.canVoteSite(site)).thenReturn(true) + .thenThrow(new AssertionError("entity task reread cleared TEMP_ONLY vote data")); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder( + "CanVoteSites", "LastVotes", requests); + placeholder.setUseCache(true, "CanVoteSites"); + placeholder.getCache().get("CanVoteSites").put(fixture.uuid, "old"); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUpdate(fixture.votingUser, false); + + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + task.getValue().run(); + assertEquals("1", placeholder.getCache().get("CanVoteSites").get(fixture.uuid)); + assertEquals(0, requests.get(), "the entity task must publish the captured result directly"); + verify(fixture.votingUser).canVoteSite(site); + } + + @Test + void unpublishedReloadGenerationCannotBypassPlatformClassification() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + + fixture.placeholders.onUpdate(fixture.votingUser, true); + + assertEquals(0, requests.get(), "readers must use the last complete published classification"); + verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void offlineAllCacheUsesTheWorkerSafePlatformFallback() { + Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); + when(fixture.votingUser.getSitesNotVotedOnWithoutOnlinePermissions()).thenReturn(4); + when(fixture.votingUser.getTotalNumberOfSitesWithoutOnlinePermissions()).thenReturn(6); + AtomicInteger liveRequests = new AtomicInteger(); + PlaceHolder available = fixture.cachedPlaceholder("SitesAvailable", "LastVotes", liveRequests); + PlaceHolder total = fixture.cachedPlaceholder("SitesAvailableTotal", "LastVotes", liveRequests); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(available)); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(total)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + assertEquals("4", available.getCache().get("sitesavailable").get(fixture.uuid)); + assertEquals("6", total.getCache().get("sitesavailabletotal").get(fixture.uuid)); + assertEquals(0, liveRequests.get(), "offline fallback must not resolve live player permissions"); + verify(fixture.scheduler, never()).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void retiredEntityFallsBackToOfflineWorkerUpdate() { + Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); + when(fixture.votingUser.getSitesNotVotedOnWithoutOnlinePermissions()).thenReturn(4); + AtomicInteger liveRequests = new AtomicInteger(); + PlaceHolder available = fixture.cachedPlaceholder( + "SitesAvailable", "LastVotes", liveRequests); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(available)); + fixture.placeholders.publishUserDataChangePlaceholders(); + fixture.presence.playerOnline(fixture.player); + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + when(fixture.scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenAnswer(call -> { + call.getArgument(2, Runnable.class).run(); + return CompletableFuture.completedFuture(EntityTaskResult.ENTITY_RETIRED); + }); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + assertEquals("4", available.getCache().get("sitesavailable").get(fixture.uuid)); + assertEquals(0, liveRequests.get(), "retirement fallback must not read live player permissions"); + } + + @Test + void allPlayerCacheCompletesOfflineFallbackWhenQuitPrecedesEntityTask() { + Fixture fixture = new Fixture(PlaceholderCacheLevel.AUTOALL); + when(fixture.votingUser.getSitesNotVotedOnWithoutOnlinePermissions()).thenReturn(3); + AtomicInteger liveRequests = new AtomicInteger(); + PlaceHolder available = fixture.cachedPlaceholder( + "SitesAvailable", "LastVotes", liveRequests); + fixture.placeholders.getPlaceholders().add( + fixture.placeholders.platformOwnedWithOfflineWorkerFallback(available)); + fixture.placeholders.publishUserDataChangePlaceholders(); + fixture.presence.playerOnline(fixture.player); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), task.capture(), eq(fixture.player)); + fixture.presence.playerOffline(fixture.uuid); + fixture.placeholders.onLogout(fixture.uuid); + task.getValue().run(); + + assertEquals("3", available.getCache().get("sitesavailable").get(fixture.uuid)); + assertEquals(0, liveRequests.get(), "quit fallback must not access live player permissions"); + } + + @Test + void playerDependentUpdateMovesToTheReplacementEntityOwner() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder("LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), tasks.capture(), eq(fixture.player)); + + Player replacement = mock(Player.class); + when(replacement.getUniqueId()).thenReturn(fixture.uuid); + fixture.presence.playerOffline(fixture.uuid); + fixture.presence.playerOnline(replacement); + tasks.getValue().run(); + assertEquals(0, requests.get(), "the stale owner task must not touch the replacement player"); + + verify(fixture.scheduler).runTask(eq(fixture.plugin), tasks.capture(), eq(replacement)); + tasks.getAllValues().get(1).run(); + assertEquals(1, requests.get()); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + } + + @Test + void staleEligibilityUpdateCannotOverwriteANewerReplacementOwnerUpdate() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + Player replacement = mock(Player.class); + when(replacement.getUniqueId()).thenReturn(fixture.uuid); + VoteSite site = mock(VoteSite.class); + when(site.isHidden()).thenReturn(false); + when(site.getPermissionToView()).thenReturn(""); + when(fixture.voteSiteManager.getVoteSitesEnabled()).thenReturn(new ArrayList<>(List.of(site))); + when(fixture.votingUser.canVoteSite(site)).thenReturn(true, false); + AtomicInteger requests = new AtomicInteger(); + PlaceHolder placeholder = fixture.cachedPlaceholder( + "CanVoteSites", "LastVotes", requests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(placeholder)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + fixture.presence.playerOffline(fixture.uuid); + fixture.presence.playerOnline(replacement); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), tasks.capture(), any(Player.class)); + tasks.getAllValues().get(1).run(); + assertEquals("0", placeholder.getCache().get("canvotesites").get(fixture.uuid)); + tasks.getAllValues().get(0).run(); + assertEquals("0", placeholder.getCache().get("canvotesites").get(fixture.uuid)); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class)); + assertEquals(0, requests.get()); + } + + @Test + void narrowUpdateDoesNotCancelUnrelatedWarmupPlaceholders() { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger changedRequests = new AtomicInteger(); + AtomicInteger warmupRequests = new AtomicInteger(); + PlaceHolder changed = fixture.cachedPlaceholder( + "Changed", "LastVotes", changedRequests); + PlaceHolder warmupOnly = fixture.cachedPlaceholder( + "WarmupOnly", "Unrelated", warmupRequests); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(changed)); + fixture.placeholders.getPlaceholders().add(fixture.placeholders.platformOwned(warmupOnly)); + fixture.placeholders.publishUserDataChangePlaceholders(); + + fixture.placeholders.onUpdate(fixture.votingUser, true); + fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + + ArgumentCaptor tasks = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler, times(2)).runTask(eq(fixture.plugin), tasks.capture(), eq(fixture.player)); + tasks.getAllValues().get(1).run(); + tasks.getAllValues().get(0).run(); + assertEquals(1, changedRequests.get()); + assertEquals(1, warmupRequests.get(), "narrow changes must not cancel unrelated warmup work"); + } + + @Test + void concurrentCalculatingUpdatesKeepBothCachesCoherent() throws Exception { + Fixture fixture = new Fixture(); + fixture.presence.playerOnline(fixture.player); + AtomicInteger sequence = new AtomicInteger(); + CalculatingPlaceholder placeholder = new CalculatingPlaceholder<>("Next_site") { + @Override public String placeholderRequest(VotingPluginUser user, String identifier) { + return getCacheData().get(user.getJavaUUID()); + } + @Override public String placeholderDataRequest(VotingPluginUser user, String identifier) { + return Integer.toString(sequence.incrementAndGet()); + } + }; + placeholder.updateDataKey("LastVotes").setUseCache(true, "next_site"); + fixture.publish(placeholder); + + ExecutorService workers = Executors.newFixedThreadPool(8); + try { + List> updates = new ArrayList<>(); + for (int i = 0; i < 100; i++) { + boolean bulkUpdate = i % 2 == 0; + updates.add(() -> { + if (bulkUpdate) fixture.placeholders.onUpdate(fixture.votingUser, true); + else fixture.placeholders.onUserDataChange(fixture.advancedUser, "LastVotes"); + return null; + }); + } + workers.invokeAll(updates).forEach(result -> { + try { result.get(); } + catch (Exception failure) { throw new AssertionError(failure); } + }); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + assertEquals(placeholder.getCacheData().get(fixture.uuid), + placeholder.getCache().get("next_site").get(fixture.uuid)); + assertEquals(1, placeholder.getCache().size()); + } + + private static final class Fixture { + final UUID uuid = UUID.randomUUID(); + final VotingPluginMain plugin = mock(VotingPluginMain.class); + final Config config = mock(Config.class); + final UserManager userManager = mock(UserManager.class); + final com.bencodez.advancedcore.api.user.UserManager advancedUserManager = + mock(com.bencodez.advancedcore.api.user.UserManager.class); + final UserDataManager dataManager = mock(UserDataManager.class); + final java.util.concurrent.ScheduledExecutorService storageWorker = + mock(java.util.concurrent.ScheduledExecutorService.class); + final VotingPluginUser votingUser = mock(VotingPluginUser.class); + final VoteSiteManager voteSiteManager = mock(VoteSiteManager.class); + final AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class); + final BukkitScheduler scheduler = mock(BukkitScheduler.class); + final PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + final Player player = mock(Player.class); + final PlaceHolders placeholders; + + Fixture() { + this(PlaceholderCacheLevel.AUTO); + } + + Fixture(PlaceholderCacheLevel level) { + when(plugin.getConfigFile()).thenReturn(config); + when(config.getPlaceholderCacheLevel()).thenReturn(level); + when(plugin.getVotingPluginUserManager()).thenReturn(userManager); + when(plugin.getVoteSiteManager()).thenReturn(voteSiteManager); + when(plugin.getUserManager()).thenReturn(advancedUserManager); + when(advancedUserManager.getDataManager()).thenReturn(dataManager); + when(dataManager.getTimer()).thenReturn(storageWorker); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) + .when(storageWorker).execute(any(Runnable.class)); + when(userManager.getVotingPluginUser(advancedUser)).thenReturn(votingUser); + when(userManager.getVotingPluginUser(uuid, false)).thenReturn(votingUser); + when(votingUser.isCached()).thenReturn(true); + when(votingUser.getJavaUUID()).thenReturn(uuid); + when(votingUser.getUUID()).thenReturn(uuid.toString()); + when(advancedUser.getJavaUUID()).thenReturn(uuid); + doThrow(new AssertionError("worker callback accessed live Bukkit online state")) + .when(advancedUser).isOnline(); + when(plugin.getPlaceholderPlayerPresence()).thenReturn(presence); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(player.getUniqueId()).thenReturn(uuid); + placeholders = new PlaceHolders(plugin); + } + + PlaceHolder cachedPlaceholder(String key, AtomicInteger requests) { + return cachedPlaceholder(key, key, requests); + } + + PlaceHolder cachedPlaceholder(String identifier, String key, AtomicInteger requests) { + PlaceHolder placeholder = new PlaceHolder<>(identifier) { + @Override public String placeholderRequest(VotingPluginUser user, String identifier) { + requests.incrementAndGet(); + return "value"; + } + }; + placeholder.updateDataKey(key).setUseCache(true, identifier.toLowerCase()); + return placeholder; + } + + void publish(PlaceHolder placeholder) { + placeholders.getPlaceholders().add(placeholder); + placeholders.publishUserDataChangePlaceholders(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java new file mode 100644 index 0000000000..01615896d9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/placeholders/PlaceholderPlayerPresenceTest.java @@ -0,0 +1,116 @@ +package com.bencodez.votingplugin.placeholders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class PlaceholderPlayerPresenceTest { + @Test + void joinQuitReloadAndDisableSnapshotsStayConsistent() { + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + Player first = player(firstId); + Player second = player(secondId); + + assertFalse(presence.isOnline(firstId)); + AtomicInteger offlinePublications = new AtomicInteger(); + assertTrue(presence.runIfOffline(firstId, offlinePublications::incrementAndGet)); + assertEquals(1, offlinePublications.get()); + presence.playerOnline(first); + assertTrue(presence.isOnline(firstId)); + assertSame(first, presence.schedulerOwner(firstId)); + assertFalse(presence.runIfOffline(firstId, offlinePublications::incrementAndGet)); + assertEquals(1, offlinePublications.get()); + + presence.playerOffline(firstId); + assertFalse(presence.isOnline(firstId)); + UUID storageId = UUID.randomUUID(); + presence.playerOnline(storageId, first); + assertTrue(presence.isOnline(storageId)); + assertSame(first, presence.schedulerOwner(storageId)); + assertEquals(storageId, presence.storageUuid(first)); + presence.playerOffline(storageId, second); + assertTrue(presence.isOnline(storageId), "a replacement owner must not retire the captured player"); + Player refreshedFirst = player(firstId); + presence.replace(List.of(refreshedFirst, second)); + assertTrue(presence.isOnline(storageId), "reload must retain the authoritative storage UUID"); + assertFalse(presence.isOnline(firstId)); + assertSame(refreshedFirst, presence.schedulerOwner(storageId)); + assertTrue(presence.isOnline(secondId)); + presence.playerOffline(storageId, refreshedFirst); + assertFalse(presence.isOnline(storageId)); + presence.replace(List.of(first, second)); + assertTrue(presence.isOnline(firstId)); + assertTrue(presence.isOnline(secondId)); + + presence.clear(); + assertFalse(presence.isOnline(firstId)); + assertFalse(presence.isOnline(secondId)); + } + + @Test + void snapshotReplacementDoesNotLoseConcurrentLifecycleChanges() throws Exception { + PlaceholderPlayerPresence presence = new PlaceholderPlayerPresence(); + UUID firstId = UUID.randomUUID(); + UUID joinedId = UUID.randomUUID(); + Player first = player(firstId); + Player joined = player(joinedId); + CountDownLatch resolving = new CountDownLatch(1); + CountDownLatch releaseResolution = new CountDownLatch(1); + CountDownLatch joinStarted = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future refresh = workers.submit(() -> presence.replace(() -> { + resolving.countDown(); + await(releaseResolution); + return List.of(first); + }, player -> firstId)); + assertTrue(resolving.await(2, TimeUnit.SECONDS)); + Future join = workers.submit(() -> { + joinStarted.countDown(); + presence.playerOnline(joinedId, joined); + }); + assertTrue(joinStarted.await(2, TimeUnit.SECONDS)); + assertFalse(join.isDone(), "join must wait for the atomic snapshot replacement"); + releaseResolution.countDown(); + refresh.get(2, TimeUnit.SECONDS); + join.get(2, TimeUnit.SECONDS); + assertSame(first, presence.schedulerOwner(firstId)); + assertSame(joined, presence.schedulerOwner(joinedId)); + } finally { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + private void await(CountDownLatch latch) { + try { + if (!latch.await(2, TimeUnit.SECONDS)) throw new AssertionError("timed out waiting for test release"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } + + private Player player(UUID uuid) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + return player; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java index 175c91407d..4eb786f528 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java @@ -81,6 +81,24 @@ void rejectedEveryFallbackCallsRejectedOnlyWhenTaskNeverBegan() { assertEquals(1, rejected.get()); } + @Test + void retiredEntityRunsDistinctGlobalSafeFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenAnswer(invocation -> { + invocation.getArgument(2, Runnable.class).run(); + return CompletableFuture.completedFuture(EntityTaskResult.ENTITY_RETIRED); + }); + AtomicInteger entity = new AtomicInteger(); + AtomicInteger fallback = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, + entity::incrementAndGet, fallback::incrementAndGet, () -> { }); + + assertEquals(0, entity.get()); + assertEquals(1, fallback.get()); + } + private static Fixture fixture() { VotingPluginMain plugin = mock(VotingPluginMain.class); BukkitScheduler scheduler = mock(BukkitScheduler.class);