From c414705b5009fb194c88a08c13e162db3bdc3496 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Sat, 9 May 2026 21:25:19 -0400 Subject: [PATCH 01/15] Adds pv use/see level methods to CorePlayer --- .../bungee/abstraction/BungeePlayer.java | 21 ++++++++++++++++++- .../common/abstraction/CorePlayer.java | 4 ++++ .../velocity/abstraction/VelocityPlayer.java | 20 ++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java index 33fc178..51d7b06 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java @@ -19,6 +19,8 @@ public class BungeePlayer implements CorePlayer { private String cachedLeaveMessage; private boolean disconnecting = false; private boolean premiumVanishHidden = false; + private int pvUseLevel = 0; + private int pvSeeLevel = 0; public BungeePlayer(ProxiedPlayer bungeePlayer) { this.bungeePlayer = bungeePlayer; @@ -103,9 +105,26 @@ public void setDisconnecting() { public boolean getPremiumVanishHidden() { return premiumVanishHidden; } - @Override public void setPremiumVanishHidden(boolean premiumVanishHidden) { this.premiumVanishHidden = premiumVanishHidden; } + + @Override + public int getPremiumVanishUseLevel() { + return pvUseLevel; + } + @Override + public void setPremiumVanishUseLevel(int pvUseLevel) { + this.pvUseLevel = pvUseLevel; + } + + @Override + public int getPremiumVanishSeeLevel() { + return pvSeeLevel; + } + @Override + public void setPremiumVanishSeeLevel(int pvSeeLevel) { + this.pvSeeLevel = pvSeeLevel; + } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java index eb91b16..80f03f4 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java @@ -33,4 +33,8 @@ public interface CorePlayer extends CoreCommandSender { boolean getPremiumVanishHidden(); void setPremiumVanishHidden(boolean premiumVanishHidden); + int getPremiumVanishUseLevel(); + void setPremiumVanishUseLevel(int level); + int getPremiumVanishSeeLevel(); + void setPremiumVanishSeeLevel(int level); } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java index 8a2ee92..27ea041 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java @@ -20,6 +20,8 @@ public class VelocityPlayer implements CorePlayer { private String cachedLeaveMessage; private boolean disconnecting = false; private boolean premiumVanishHidden = false; + private int pvUseLevel = 0; + private int pvSeeLevel = 0; public VelocityPlayer(Player velocityPlayer) { this.velocityPlayer = velocityPlayer; @@ -116,4 +118,22 @@ public boolean getPremiumVanishHidden() { public void setPremiumVanishHidden(boolean premiumVanishHidden) { this.premiumVanishHidden = premiumVanishHidden; } + + @Override + public int getPremiumVanishUseLevel() { + return pvUseLevel; + } + @Override + public void setPremiumVanishUseLevel(int pvUseLevel) { + this.pvUseLevel = pvUseLevel; + } + + @Override + public int getPremiumVanishSeeLevel() { + return pvSeeLevel; + } + @Override + public void setPremiumVanishSeeLevel(int pvSeeLevel) { + this.pvSeeLevel = pvSeeLevel; + } } From 18fb581e6ac090469b5d44227d716a0c3e4a33ca Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Sat, 9 May 2026 22:44:34 -0400 Subject: [PATCH 02/15] Adds config NotifyVanishEnabledPlayersOnSilentMove --- .../common/config/PluginConfig.java | 26 +++++++++++-------- src/main/resources/config.yml | 5 ++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java index 5567271..88cdf2b 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java @@ -119,11 +119,13 @@ public final class PluginConfig { // Third-party plugin integration flags @Getter private boolean SVTreatVanishedPlayersAsSilent; @Getter private boolean SVRemoveVanishedPlayersFromPlayerCount; + @Getter private boolean SVNotifyVanishEnabledPlayersOnSilentMove; @Getter private boolean PVTreatVanishedPlayersAsSilent; @Getter private boolean PVRemoveVanishedPlayersFromPlayerCount; @Getter private boolean PVSpoofJoinMessageOnShow; @Getter private boolean PVSpoofLeaveMessageOnHide; @Getter private boolean PVTreatVanishedOnJoin; + @Getter private boolean PVNotifyVanishEnabledPlayersOnSilentMove; @Getter private boolean shouldSuppressLimboSwap; @Getter private boolean shouldSuppressLimboJoin; @Getter private boolean shouldSuppressLimboLeave; @@ -219,17 +221,19 @@ public void reload() { serverJoinMessageDisabled = config.getStringList("Settings.IgnoreJoinMessagesList"); serverLeaveMessageDisabled = config.getStringList("Settings.IgnoreLeaveMessagesList"); - PPBRequestTimeout = config.getLong("OtherPlugins.PAPIProxyBridge.RequestTimeout"); - SVTreatVanishedPlayersAsSilent = config.getBoolean("OtherPlugins.SayanVanish.TreatVanishedPlayersAsSilent"); - SVRemoveVanishedPlayersFromPlayerCount = config.getBoolean("OtherPlugins.SayanVanish.RemoveVanishedPlayersFromPlayerCount"); - PVTreatVanishedPlayersAsSilent = config.getBoolean("OtherPlugins.PremiumVanish.TreatVanishedPlayersAsSilent"); - PVRemoveVanishedPlayersFromPlayerCount = config.getBoolean("OtherPlugins.PremiumVanish.RemoveVanishedPlayersFromPlayerCount"); - PVSpoofJoinMessageOnShow = config.getBoolean("OtherPlugins.PremiumVanish.SpoofJoinMessageOnShow"); - PVSpoofLeaveMessageOnHide = config.getBoolean("OtherPlugins.PremiumVanish.SpoofLeaveMessageOnHide"); - PVTreatVanishedOnJoin = config.getBoolean("OtherPlugins.PremiumVanish.TreatVanishedOnJoin"); - shouldSuppressLimboSwap = config.getBoolean("OtherPlugins.LimboAPI.SuppressSwapMessages"); - shouldSuppressLimboJoin = config.getBoolean("OtherPlugins.LimboAPI.SuppressJoinMessages"); - shouldSuppressLimboLeave = config.getBoolean("OtherPlugins.LimboAPI.SuppressLeaveMessages"); + PPBRequestTimeout = config.getLong("OtherPlugins.PAPIProxyBridge.RequestTimeout"); + SVTreatVanishedPlayersAsSilent = config.getBoolean("OtherPlugins.SayanVanish.TreatVanishedPlayersAsSilent"); + SVRemoveVanishedPlayersFromPlayerCount = config.getBoolean("OtherPlugins.SayanVanish.RemoveVanishedPlayersFromPlayerCount"); + SVNotifyVanishEnabledPlayersOnSilentMove = config.getBoolean("OtherPlugins.SayanVanish.NotifyVanishEnabledPlayersOnSilentMove"); + PVTreatVanishedPlayersAsSilent = config.getBoolean("OtherPlugins.PremiumVanish.TreatVanishedPlayersAsSilent"); + PVRemoveVanishedPlayersFromPlayerCount = config.getBoolean("OtherPlugins.PremiumVanish.RemoveVanishedPlayersFromPlayerCount"); + PVSpoofJoinMessageOnShow = config.getBoolean("OtherPlugins.PremiumVanish.SpoofJoinMessageOnShow"); + PVSpoofLeaveMessageOnHide = config.getBoolean("OtherPlugins.PremiumVanish.SpoofLeaveMessageOnHide"); + PVTreatVanishedOnJoin = config.getBoolean("OtherPlugins.PremiumVanish.TreatVanishedOnJoin"); + PVNotifyVanishEnabledPlayersOnSilentMove = config.getBoolean("OtherPlugins.PremiumVanish.NotifyVanishEnabledPlayersOnSilentMove"); + shouldSuppressLimboSwap = config.getBoolean("OtherPlugins.LimboAPI.SuppressSwapMessages"); + shouldSuppressLimboJoin = config.getBoolean("OtherPlugins.LimboAPI.SuppressJoinMessages"); + shouldSuppressLimboLeave = config.getBoolean("OtherPlugins.LimboAPI.SuppressLeaveMessages"); plugin.getCoreLogger().setDebug(config.getBoolean("debug")); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 25f3bb8..95702ec 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -244,6 +244,8 @@ OtherPlugins: TreatVanishedPlayersAsSilent: true # Vanished players will not be counted in the player count placeholders RemoveVanishedPlayersFromPlayerCount: true + # Send silenced messages to players with the permission sayanvanish.vanish.use (permission must be set on the proxy) + NotifyVanishEnabledPlayersOnSilentMove: false PremiumVanish: # Vanished players will not trigger messages aside from # silent messages sent to players with networkjoinmessages.silent permission @@ -257,6 +259,9 @@ OtherPlugins: # Treats a player with the permission pv.joinvanished as vanished on join (permission must be set on the proxy) # This is to be used in conjunction with the PremiumVanish 'AutoVanishOnJoin' option TreatVanishedOnJoin: false + # Send silenced messages to players with the permission pv.see[.levelX] (permission must be set on the proxy) + # Requires players to rejoin upon changes to their level or setting this setting to true + NotifyVanishEnabledPlayersOnSilentMove: false LimboAPI: # No swap messages for players swapping to or from limbo servers SuppressSwapMessages: true From 82fc46f1c8f030875743979eb2de40967657806f Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Sun, 10 May 2026 21:33:49 -0400 Subject: [PATCH 03/15] Updates PV vanish levels on join If the new config option `PVNotifyVanishEnabledPlayersOnSilentMove` is enabled, both `pv.use` and `pv.see` levels are determined upon the player joining the proxy. Logic may be costly, but there is no alternative currently known. --- .../common/listeners/CorePlayerListener.java | 10 ++++- .../common/util/PremiumVanishLevelUtil.java | 40 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/main/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtil.java diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java index 5a7115d..b7c6974 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java @@ -16,6 +16,7 @@ import xyz.earthcow.networkjoinmessages.common.storage.PlayerJoinTracker; import xyz.earthcow.networkjoinmessages.common.MessageType; import xyz.earthcow.networkjoinmessages.common.util.PlaceholderResolver; +import xyz.earthcow.networkjoinmessages.common.util.PremiumVanishLevelUtil; /** * Routes platform-level player events (join, swap, disconnect) to the appropriate handlers. @@ -119,8 +120,13 @@ private void handleJoin(@NotNull CorePlayer player, @NotNull CoreBackendServer s player.setLastKnownConnectedServer(server); PremiumVanish pv = plugin.getVanishAPI(); - if (pv != null && pv.isVanished(player.getUniqueId())) { - player.setPremiumVanishHidden(true); + if (pv != null) { + if (config.isPVNotifyVanishEnabledPlayersOnSilentMove()) { + PremiumVanishLevelUtil.updateVanishLevels(player); + } + if (pv.isVanished(player.getUniqueId())) { + player.setPremiumVanishHidden(true); + } } leaveMessageCache.refresh(player); diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtil.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtil.java new file mode 100644 index 0000000..06b3c5b --- /dev/null +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtil.java @@ -0,0 +1,40 @@ +package xyz.earthcow.networkjoinmessages.common.util; + +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; + +public final class PremiumVanishLevelUtil { + + private static final int MAX_LEVEL = 100; + + /** + * Determines a player's vanish level by scanning down from {@link PremiumVanishLevelUtil#MAX_LEVEL} to obtain + * their highest level + * @param player The player to analyze + * @param forUse If {@code true}, we use {@code pv.use} otherwise we use {@code pv.see} + * @return The determined highest vanish level for the specified player + */ + private static int determineVanishLevel(CorePlayer player, boolean forUse) { + String base = forUse ? "pv.use" : "pv.see"; + String prefix = base + ".level"; + for (int i = MAX_LEVEL; i >= 1; i--) { + if (player.hasPermission(prefix + i)) { + return i; + } + } + // The base permission (pv.use or pv.see) is a level of 1 + return player.hasPermission(base) ? 1 : 0; + } + + /** + * Updates a player's PremiumVanish use and see vanish levels. Uses + * {@link PremiumVanishLevelUtil#determineVanishLevel(CorePlayer, boolean)} to do so. The logic is unpreventably + * costly as it iterates down from {@link PremiumVanishLevelUtil#MAX_LEVEL} checking player permission each time. + * Should be called asynchronously to the main thread. + * @param player The player in which vanish levels will be updated for + */ + public static void updateVanishLevels(CorePlayer player) { + player.setPremiumVanishUseLevel(determineVanishLevel(player, true)); + player.setPremiumVanishSeeLevel(determineVanishLevel(player, false)); + } + +} From 44a1103e80ed3a0b4b092dd09c789fff919cd341 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Sun, 10 May 2026 22:22:57 -0400 Subject: [PATCH 04/15] Silent notify vanish user impl --- .../networkjoinmessages/common/Core.java | 2 +- .../common/MessageHandler.java | 9 +--- .../common/broadcast/ReceiverResolver.java | 43 ++++++++++++++++++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java index 860b302..0b9f8f4 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java @@ -81,7 +81,7 @@ public Core(CorePlugin plugin, PremiumVanish premiumVanish) { // Message building MessageFormatter messageFormatter = new MessageFormatter(plugin, config, sayanVanishHook); - ReceiverResolver receiverResolver = new ReceiverResolver(plugin, config); + ReceiverResolver receiverResolver = new ReceiverResolver(plugin, config, sayanVanishHook != null, premiumVanish != null); MessageHandler messageHandler = new MessageHandler(plugin, config, stateStore, placeholderResolver, receiverResolver); // Player event helpers diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java index ac98440..16b8aef 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java @@ -9,7 +9,6 @@ import xyz.earthcow.networkjoinmessages.common.config.PluginConfig; import xyz.earthcow.networkjoinmessages.common.player.PlayerStateStore; import xyz.earthcow.networkjoinmessages.common.util.Formatter; -import xyz.earthcow.networkjoinmessages.common.MessageType; import xyz.earthcow.networkjoinmessages.common.util.PlaceholderResolver; import java.util.*; @@ -134,12 +133,8 @@ private void broadcastSilentMessage( ) { sendSilentConsoleMessage(type, from, to, parseTarget); - if (!config.isNotifyAdminsOnSilentMove()) return; - - for (CorePlayer player : plugin.getAllPlayers()) { - if (player.hasPermission("networkjoinmessages.silent")) { - sendMessage(player, config.getSilentPrefix() + text, parseTarget); - } + for (CorePlayer player : receiverResolver.getSilentReceivers(parseTarget)) { + sendMessage(player, config.getSilentPrefix() + text, parseTarget); } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java index 22ed049..5e8cbef 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java @@ -1,5 +1,6 @@ package xyz.earthcow.networkjoinmessages.common.broadcast; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import xyz.earthcow.networkjoinmessages.common.abstraction.CoreBackendServer; import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; @@ -18,10 +19,15 @@ public final class ReceiverResolver { private final CorePlugin plugin; private final PluginConfig config; + private final boolean hasSayanVanish; + private final boolean hasPremiumVanish; - public ReceiverResolver(CorePlugin plugin, PluginConfig config) { + + public ReceiverResolver(CorePlugin plugin, PluginConfig config, boolean hasSayanVanish, boolean hasPremiumVanish) { this.plugin = plugin; this.config = config; + this.hasSayanVanish = hasSayanVanish; + this.hasPremiumVanish = hasPremiumVanish; } // --- Audience resolution --- @@ -77,6 +83,41 @@ private List resolve( return receivers; } + /** + * Collects and returns all the {@link CorePlayer}s who should receive silent messages.
+ * A player will receive a silent message if any one of the following is true: + *
    + *
  1. If {@code NotifyAdminsOnSilentMove} is enabled and the player holds the + * {@code networkjoinmessages.silent} permission
  2. + *
  3. If SayanVanish is present, {@code SVNotifyVanishEnabledPlayersOnSilentMove} is true, and the player holds + * the {@code sayanvanish.vanish.use} permission
  4. + *
  5. If PremiumVanish is present, {@code PVNotifyVanishEnabledPlayersOnSilentMove} is true, and the player's + * {@code pv.see} level is the same as or greater than the trigger player's {@code pv.use} level
  6. + *
+ * @param triggerPlayer The player who triggered a message + * @return The list of players who should receive the silent message + */ + public List getSilentReceivers(@NotNull CorePlayer triggerPlayer) { + List silentReceivers = new ArrayList<>(); + + for (CorePlayer player : plugin.getAllPlayers()) { + if (config.isNotifyAdminsOnSilentMove() && player.hasPermission("networkjoinmessages.silent")) { + silentReceivers.add(player); + continue; + } + if (hasSayanVanish && config.isSVNotifyVanishEnabledPlayersOnSilentMove() + && player.hasPermission("sayanvanish.vanish.use")) { + silentReceivers.add(player); + continue; + } + if (hasPremiumVanish && config.isPVNotifyVanishEnabledPlayersOnSilentMove() + && (player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel())) { + silentReceivers.add(player); + } + } + return silentReceivers; + } + // --- Blacklist / whitelist checks --- /** From f4681e27ad35b0420e3a44b764cb00c3554156a3 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Sun, 10 May 2026 22:32:16 -0400 Subject: [PATCH 05/15] Prevents triggerPlayer from being null --- .../common/MessageHandler.java | 15 ++++++++------- .../common/listeners/CorePlayerListener.java | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java index 16b8aef..1d070f6 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java @@ -98,11 +98,11 @@ public void broadcastMessage(String text, MessageType type, String from, String public void broadcastMessage( String text, MessageType type, String from, String to, - @Nullable CorePlayer parseTarget, + @NotNull CorePlayer parseTarget, boolean silent ) { if (silent) { - broadcastSilentMessage(text, type, from, to, parseTarget); + broadcastSilentMessage(text, type, from, to, parseTarget, true); return; } @@ -126,15 +126,16 @@ public void broadcastMessage( } } - private void broadcastSilentMessage( + public void broadcastSilentMessage( @NotNull String text, @NotNull MessageType type, @NotNull String from, @NotNull String to, - @Nullable CorePlayer parseTarget + @NotNull CorePlayer triggerPlayer, + boolean isParseTarget ) { - sendSilentConsoleMessage(type, from, to, parseTarget); + sendSilentConsoleMessage(type, from, to, triggerPlayer); - for (CorePlayer player : receiverResolver.getSilentReceivers(parseTarget)) { - sendMessage(player, config.getSilentPrefix() + text, parseTarget); + for (CorePlayer player : receiverResolver.getSilentReceivers(triggerPlayer)) { + sendMessage(player, config.getSilentPrefix() + text, isParseTarget ? triggerPlayer : null); } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java index b7c6974..f778882 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java @@ -173,7 +173,7 @@ private void broadcastLeave(@NotNull CorePlayer player) { String serverName = player.getCurrentServer().getName(); // Pass null as parseTarget — player is gone, placeholders already resolved in cache - messageHandler.broadcastMessage(message, MessageType.LEAVE, serverName, "", null, silent); + messageHandler.broadcastSilentMessage(message, MessageType.LEAVE, serverName, "", player, false); fireLeaveEvent(player, serverName, message, silent); } From b5bba78ba366b3661518755824ec40b734cbede2 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Tue, 12 May 2026 23:16:28 -0400 Subject: [PATCH 06/15] Prevent extra List & extra loop Transforms `ReceiverResolver#getSilentReceivers` to `ReceiverResolver#isSilentReceiver` to prevent the creation of an intermediate List and double loop. Also moves the ternary statement for determining the parseTarget outside the player loop. --- .../common/MessageHandler.java | 15 +++++---- .../common/broadcast/ReceiverResolver.java | 32 +++++++------------ 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java index 1d070f6..7b5ab16 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java @@ -127,15 +127,18 @@ public void broadcastMessage( } public void broadcastSilentMessage( - @NotNull String text, @NotNull MessageType type, - @NotNull String from, @NotNull String to, - @NotNull CorePlayer triggerPlayer, - boolean isParseTarget + @NotNull String text, @NotNull MessageType type, + @NotNull String from, @NotNull String to, + @NotNull CorePlayer triggerPlayer, + boolean isParseTarget ) { sendSilentConsoleMessage(type, from, to, triggerPlayer); - for (CorePlayer player : receiverResolver.getSilentReceivers(triggerPlayer)) { - sendMessage(player, config.getSilentPrefix() + text, isParseTarget ? triggerPlayer : null); + CorePlayer parseTarget = isParseTarget ? triggerPlayer : null; + + for (CorePlayer player : plugin.getAllPlayers()) { + if (!receiverResolver.isSilentReceiver(player, triggerPlayer)) continue; + sendMessage(player, config.getSilentPrefix() + text, parseTarget); } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java index 5e8cbef..bd509d8 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java @@ -84,7 +84,7 @@ private List resolve( } /** - * Collects and returns all the {@link CorePlayer}s who should receive silent messages.
+ * Determines if a player should receive a silent message.
* A player will receive a silent message if any one of the following is true: *
    *
  1. If {@code NotifyAdminsOnSilentMove} is enabled and the player holds the @@ -94,28 +94,18 @@ private List resolve( *
  2. If PremiumVanish is present, {@code PVNotifyVanishEnabledPlayersOnSilentMove} is true, and the player's * {@code pv.see} level is the same as or greater than the trigger player's {@code pv.use} level
  3. *
+ * @param player The player to determine whether they are a silent receiver or not * @param triggerPlayer The player who triggered a message - * @return The list of players who should receive the silent message + * @return Whether the player is a silent receiver (true) or not (false) */ - public List getSilentReceivers(@NotNull CorePlayer triggerPlayer) { - List silentReceivers = new ArrayList<>(); - - for (CorePlayer player : plugin.getAllPlayers()) { - if (config.isNotifyAdminsOnSilentMove() && player.hasPermission("networkjoinmessages.silent")) { - silentReceivers.add(player); - continue; - } - if (hasSayanVanish && config.isSVNotifyVanishEnabledPlayersOnSilentMove() - && player.hasPermission("sayanvanish.vanish.use")) { - silentReceivers.add(player); - continue; - } - if (hasPremiumVanish && config.isPVNotifyVanishEnabledPlayersOnSilentMove() - && (player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel())) { - silentReceivers.add(player); - } - } - return silentReceivers; + public boolean isSilentReceiver(@NotNull CorePlayer player, @NotNull CorePlayer triggerPlayer) { + if (config.isNotifyAdminsOnSilentMove() && player.hasPermission("networkjoinmessages.silent")) + return true; + if (hasSayanVanish && config.isSVNotifyVanishEnabledPlayersOnSilentMove() + && player.hasPermission("sayanvanish.vanish.use")) + return true; + return hasPremiumVanish && config.isPVNotifyVanishEnabledPlayersOnSilentMove() + && player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel(); } // --- Blacklist / whitelist checks --- From 713dc6b4794cff2818abf75aee702b83a230f33a Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Wed, 13 May 2026 14:30:50 -0400 Subject: [PATCH 07/15] Adds config NotifyRespectVanishLevels `NotifyVanishEnabledPlayersOnSilentMove` will be modified to notify players with either `pv.use` or `pv.see` permissions. This will leave the expensive vanish level determination logic out for PV users who do not use layered permissions. To enable respecting layered permissions, this commit adds a new config option, `NotifyRespectVanishLevels`, to `PluginConfig` and `config.yml`. This and the other permission changes are yet to be implemented as this commit only adds the new permission. Also changed silenced messages to silent messages and update config comments for the new expected functionality. --- .../common/config/PluginConfig.java | 2 ++ src/main/resources/config.yml | 10 +++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java index 88cdf2b..cda800a 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java @@ -126,6 +126,7 @@ public final class PluginConfig { @Getter private boolean PVSpoofLeaveMessageOnHide; @Getter private boolean PVTreatVanishedOnJoin; @Getter private boolean PVNotifyVanishEnabledPlayersOnSilentMove; + @Getter private boolean PVNotifyRespectVanishLevels; @Getter private boolean shouldSuppressLimboSwap; @Getter private boolean shouldSuppressLimboJoin; @Getter private boolean shouldSuppressLimboLeave; @@ -231,6 +232,7 @@ public void reload() { PVSpoofLeaveMessageOnHide = config.getBoolean("OtherPlugins.PremiumVanish.SpoofLeaveMessageOnHide"); PVTreatVanishedOnJoin = config.getBoolean("OtherPlugins.PremiumVanish.TreatVanishedOnJoin"); PVNotifyVanishEnabledPlayersOnSilentMove = config.getBoolean("OtherPlugins.PremiumVanish.NotifyVanishEnabledPlayersOnSilentMove"); + PVNotifyRespectVanishLevels = config.getBoolean("OtherPlugins.PremiumVanish.NotifyRespectVanishLevels"); shouldSuppressLimboSwap = config.getBoolean("OtherPlugins.LimboAPI.SuppressSwapMessages"); shouldSuppressLimboJoin = config.getBoolean("OtherPlugins.LimboAPI.SuppressJoinMessages"); shouldSuppressLimboLeave = config.getBoolean("OtherPlugins.LimboAPI.SuppressLeaveMessages"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 95702ec..975fe4b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -244,7 +244,7 @@ OtherPlugins: TreatVanishedPlayersAsSilent: true # Vanished players will not be counted in the player count placeholders RemoveVanishedPlayersFromPlayerCount: true - # Send silenced messages to players with the permission sayanvanish.vanish.use (permission must be set on the proxy) + # Send silent messages to players with the permission sayanvanish.vanish.use (permission must be set on the proxy) NotifyVanishEnabledPlayersOnSilentMove: false PremiumVanish: # Vanished players will not trigger messages aside from @@ -259,9 +259,13 @@ OtherPlugins: # Treats a player with the permission pv.joinvanished as vanished on join (permission must be set on the proxy) # This is to be used in conjunction with the PremiumVanish 'AutoVanishOnJoin' option TreatVanishedOnJoin: false - # Send silenced messages to players with the permission pv.see[.levelX] (permission must be set on the proxy) - # Requires players to rejoin upon changes to their level or setting this setting to true + # Send silent messages to players with the permission pv.use or pv.see (permission must be set on the proxy) NotifyVanishEnabledPlayersOnSilentMove: false + # Instead of sending silent messages to all players with pv.use or pv.see, only send silent messages to players with + # a greater than or equal to see level than the player who triggered the message's use level. + # Simply put, this setting respects the layered use/see permissions set with PremiumVanish. + # Requires players to rejoin upon changes to their level or setting this setting to true + NotifyRespectVanishLevels: false LimboAPI: # No swap messages for players swapping to or from limbo servers SuppressSwapMessages: true From b8df48e07d42e268e865fac50b740272639881dd Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Wed, 13 May 2026 14:50:09 -0400 Subject: [PATCH 08/15] Impls NotifyRespectVanishLevels as outlined in 713dc6b --- .../common/broadcast/ReceiverResolver.java | 20 +++++++++++++++---- .../common/listeners/CorePlayerListener.java | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java index bd509d8..0f13570 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java @@ -91,8 +91,14 @@ private List resolve( * {@code networkjoinmessages.silent} permission *
  • If SayanVanish is present, {@code SVNotifyVanishEnabledPlayersOnSilentMove} is true, and the player holds * the {@code sayanvanish.vanish.use} permission
  • - *
  • If PremiumVanish is present, {@code PVNotifyVanishEnabledPlayersOnSilentMove} is true, and the player's - * {@code pv.see} level is the same as or greater than the trigger player's {@code pv.use} level
  • + *
  • If PremiumVanish is present, {@code PVNotifyVanishEnabledPlayersOnSilentMove} is true, then if + * {@code PVNotifyRespectVanishLevels}: + *
      + *
    • Is true and the player's {@code pv.see} level is the same as or greater than the trigger player's + * {@code pv.use} level
    • + *
    • Is false and the player holds either {@code pv.use} or {@code pv.see}
    • + *
    + *
  • * * @param player The player to determine whether they are a silent receiver or not * @param triggerPlayer The player who triggered a message @@ -104,8 +110,14 @@ public boolean isSilentReceiver(@NotNull CorePlayer player, @NotNull CorePlayer if (hasSayanVanish && config.isSVNotifyVanishEnabledPlayersOnSilentMove() && player.hasPermission("sayanvanish.vanish.use")) return true; - return hasPremiumVanish && config.isPVNotifyVanishEnabledPlayersOnSilentMove() - && player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel(); + if (hasPremiumVanish && config.isPVNotifyVanishEnabledPlayersOnSilentMove()) { + if (config.isPVNotifyRespectVanishLevels()) { + if (player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel()) + return true; + } + return (player.hasPermission("pv.use") || player.hasPermission("pv.see")); + } + return false; } // --- Blacklist / whitelist checks --- diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java index f778882..935ac7e 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java @@ -121,7 +121,7 @@ private void handleJoin(@NotNull CorePlayer player, @NotNull CoreBackendServer s PremiumVanish pv = plugin.getVanishAPI(); if (pv != null) { - if (config.isPVNotifyVanishEnabledPlayersOnSilentMove()) { + if (config.isPVNotifyVanishEnabledPlayersOnSilentMove() && config.isPVNotifyRespectVanishLevels()) { PremiumVanishLevelUtil.updateVanishLevels(player); } if (pv.isVanished(player.getUniqueId())) { From 70dda01dac9cb542e1885ec3dba56e4224465f34 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 13:17:56 -0400 Subject: [PATCH 09/15] Updates outdated comment in CorePlayerListener --- .../common/listeners/CorePlayerListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java index 935ac7e..757b858 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java @@ -172,7 +172,7 @@ private void broadcastLeave(@NotNull CorePlayer player) { boolean silent = silenceChecker.isSilent(player); String serverName = player.getCurrentServer().getName(); - // Pass null as parseTarget — player is gone, placeholders already resolved in cache + // Pass player as triggerPlayer but false for isParseTarget as the placeholders are already resolved in cache messageHandler.broadcastSilentMessage(message, MessageType.LEAVE, serverName, "", player, false); fireLeaveEvent(player, serverName, message, silent); } From 7908acba1d57fefc4ae925a0096c630a8cd921cc Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 13:18:20 -0400 Subject: [PATCH 10/15] Bumps config version --- src/main/resources/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 975fe4b..31a8d31 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -276,4 +276,4 @@ OtherPlugins: debug: false # Do not touch this -config-version: 13 \ No newline at end of file +config-version: 14 \ No newline at end of file From 8f254a4b25d0158bc578b57dc990bc4a7f092e15 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 14:05:40 -0400 Subject: [PATCH 11/15] Makes CorePlayer an abstract class & uses lombok As a result of using Lombok, `setDisconnecting` now requires a boolean argument and `getPremiumVanishHidden` was renamed to `isPremiumVanishHidden`. These changes are good for consistency and clarity anyway. --- .../bungee/abstraction/BungeePlayer.java | 67 +---------------- .../common/abstraction/CorePlayer.java | 50 ++++++------- .../common/listeners/CorePlayerListener.java | 2 +- .../listeners/CorePremiumVanishListener.java | 4 +- .../common/player/SilenceChecker.java | 4 +- .../velocity/abstraction/VelocityPlayer.java | 73 ++----------------- 6 files changed, 35 insertions(+), 165 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java index 51d7b06..32117ed 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java @@ -12,19 +12,13 @@ import java.util.UUID; -public class BungeePlayer implements CorePlayer { +public class BungeePlayer extends CorePlayer { private final ProxiedPlayer bungeePlayer; - private CoreBackendServer lastKnownConnectedServer; private final Audience audience; - private String cachedLeaveMessage; - private boolean disconnecting = false; - private boolean premiumVanishHidden = false; - private int pvUseLevel = 0; - private int pvSeeLevel = 0; public BungeePlayer(ProxiedPlayer bungeePlayer) { + super(new BungeeServer(bungeePlayer.getServer().getInfo())); this.bungeePlayer = bungeePlayer; - this.lastKnownConnectedServer = new BungeeServer(bungeePlayer.getServer().getInfo()); this.audience = BungeeMain.getInstance().getAudiences().player(bungeePlayer); } @@ -58,21 +52,11 @@ public int getConnectionIdentity() { public @Nullable CoreBackendServer getCurrentServer() { Server server = bungeePlayer.getServer(); if (server == null) { - return lastKnownConnectedServer; + return getLastKnownConnectedServer(); } return new BungeeServer(server.getInfo()); } - @Override - public @Nullable CoreBackendServer getLastKnownConnectedServer() { - return lastKnownConnectedServer; - } - - @Override - public void setLastKnownConnectedServer(CoreBackendServer server) { - lastKnownConnectedServer = server; - } - @Override public @NotNull Audience getAudience() { return audience; @@ -82,49 +66,4 @@ public void setLastKnownConnectedServer(CoreBackendServer server) { public boolean isInLimbo() { return false; } - - @Override - public String getCachedLeaveMessage() { - return cachedLeaveMessage; - } - @Override - public void setCachedLeaveMessage(String cachedLeaveMessage) { - this.cachedLeaveMessage = cachedLeaveMessage; - } - - @Override - public boolean isDisconnecting() { - return disconnecting; - } - @Override - public void setDisconnecting() { - this.disconnecting = true; - } - - @Override - public boolean getPremiumVanishHidden() { - return premiumVanishHidden; - } - @Override - public void setPremiumVanishHidden(boolean premiumVanishHidden) { - this.premiumVanishHidden = premiumVanishHidden; - } - - @Override - public int getPremiumVanishUseLevel() { - return pvUseLevel; - } - @Override - public void setPremiumVanishUseLevel(int pvUseLevel) { - this.pvUseLevel = pvUseLevel; - } - - @Override - public int getPremiumVanishSeeLevel() { - return pvSeeLevel; - } - @Override - public void setPremiumVanishSeeLevel(int pvSeeLevel) { - this.pvSeeLevel = pvSeeLevel; - } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java index 80f03f4..e2fd5a5 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java @@ -1,40 +1,34 @@ package xyz.earthcow.networkjoinmessages.common.abstraction; +import lombok.Getter; +import lombok.Setter; import net.kyori.adventure.audience.Audience; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.UUID; -public interface CorePlayer extends CoreCommandSender { +@Getter @Setter +public abstract class CorePlayer implements CoreCommandSender { + // Fields + private CoreBackendServer lastKnownConnectedServer; + private boolean disconnecting = false; + private String cachedLeaveMessage; + private boolean premiumVanishHidden = false; + private int premiumVanishUseLevel = 0; + private int premiumVanishSeeLevel = 0; + + public CorePlayer(CoreBackendServer lastKnownConnectedServer) { + this.lastKnownConnectedServer = lastKnownConnectedServer; + } + + // Abstract @NotNull - UUID getUniqueId(); - - int getConnectionIdentity(); - + public abstract UUID getUniqueId(); + public abstract int getConnectionIdentity(); @Nullable - CoreBackendServer getCurrentServer(); - - @Nullable - CoreBackendServer getLastKnownConnectedServer(); - - void setLastKnownConnectedServer(CoreBackendServer server); - + public abstract CoreBackendServer getCurrentServer(); @NotNull - Audience getAudience(); - - boolean isInLimbo(); - - String getCachedLeaveMessage(); - void setCachedLeaveMessage(String cachedLeaveMessage); - - boolean isDisconnecting(); - void setDisconnecting(); - - boolean getPremiumVanishHidden(); - void setPremiumVanishHidden(boolean premiumVanishHidden); - int getPremiumVanishUseLevel(); - void setPremiumVanishUseLevel(int level); - int getPremiumVanishSeeLevel(); - void setPremiumVanishSeeLevel(int level); + public abstract Audience getAudience(); + public abstract boolean isInLimbo(); } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java index 757b858..ca80b15 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePlayerListener.java @@ -101,7 +101,7 @@ public void onDisconnect(@NotNull CorePlayer player) { plugin.getCoreLogger().debug("Duplicate disconnect ignored for " + player.getName()); return; } - player.setDisconnecting(); + player.setDisconnecting(true); if (shouldSkipLeave(player)) { cleanup(player); diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePremiumVanishListener.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePremiumVanishListener.java index 71d241b..c7185ce 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePremiumVanishListener.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/listeners/CorePremiumVanishListener.java @@ -22,7 +22,7 @@ public CorePremiumVanishListener(@NotNull CoreLogger logger, @NotNull PluginConf } public void handlePremiumVanishShow(@NotNull CorePlayer player) { - if (!player.getPremiumVanishHidden()) return; + if (!player.isPremiumVanishHidden()) return; logger.debug("Setting PremiumVanishHidden to FALSE for " + player.getName()); player.setPremiumVanishHidden(false); if (config.isPVSpoofJoinMessageOnShow() && !stateStore.getSilentState(player)) { @@ -31,7 +31,7 @@ public void handlePremiumVanishShow(@NotNull CorePlayer player) { } public void handlePremiumVanishHide(@NotNull CorePlayer player) { - if (player.getPremiumVanishHidden()) return; + if (player.isPremiumVanishHidden()) return; logger.debug("Setting PremiumVanishHidden to TRUE for " + player.getName()); player.setPremiumVanishHidden(true); if (config.isPVSpoofLeaveMessageOnHide() && !stateStore.getSilentState(player)) { diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/player/SilenceChecker.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/player/SilenceChecker.java index 84d5c6e..59f96ac 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/player/SilenceChecker.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/player/SilenceChecker.java @@ -74,7 +74,7 @@ private boolean isSayanVanishSilent(CorePlayer player) { private boolean isPremiumVanishSilent(CorePlayer player) { return premiumVanish != null && config.isPVTreatVanishedPlayersAsSilent() - && (premiumVanish.isVanished(player.getUniqueId()) || player.getPremiumVanishHidden()); + && (premiumVanish.isVanished(player.getUniqueId()) || player.isPremiumVanishHidden()); } private void logDebugState(CorePlayer player) { @@ -88,7 +88,7 @@ private void logDebugState(CorePlayer player) { premiumVanish != null, config.isPVTreatVanishedPlayersAsSilent(), premiumVanish != null ? premiumVanish.isVanished(player.getUniqueId()) : "N/A", - player.getPremiumVanishHidden(), + player.isPremiumVanishHidden(), config.isPVTreatVanishedOnJoin(), player.hasPermission(PV_JOIN_VANISHED_PERM) )); diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java index 27ea041..34e5b1c 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java @@ -13,21 +13,15 @@ import java.util.UUID; -public class VelocityPlayer implements CorePlayer { +public class VelocityPlayer extends CorePlayer { private final Player velocityPlayer; - private CoreBackendServer lastKnownConnectedServer; private final Audience audience; - private String cachedLeaveMessage; - private boolean disconnecting = false; - private boolean premiumVanishHidden = false; - private int pvUseLevel = 0; - private int pvSeeLevel = 0; public VelocityPlayer(Player velocityPlayer) { + super(velocityPlayer.getCurrentServer().isPresent() ? + new VelocityServer(velocityPlayer.getCurrentServer().get().getServer()) + : null); this.velocityPlayer = velocityPlayer; - if (velocityPlayer.getCurrentServer().isPresent()) { - this.lastKnownConnectedServer = new VelocityServer(velocityPlayer.getCurrentServer().get().getServer()); - } this.audience = Audience.audience(velocityPlayer); } @@ -61,21 +55,11 @@ public int getConnectionIdentity() { public @Nullable CoreBackendServer getCurrentServer() { ServerConnection serverConnection = velocityPlayer.getCurrentServer().orElse(null); if (serverConnection == null) { - return lastKnownConnectedServer; + return getLastKnownConnectedServer(); } return new VelocityServer(serverConnection.getServer()); } - @Override - public @Nullable CoreBackendServer getLastKnownConnectedServer() { - return lastKnownConnectedServer; - } - - @Override - public void setLastKnownConnectedServer(CoreBackendServer server) { - lastKnownConnectedServer = server; - } - @Override public @NotNull Audience getAudience() { return audience; @@ -89,51 +73,4 @@ public boolean isInLimbo() { //noinspection ConstantValue return ((ConnectedPlayer) velocityPlayer).getConnection().getState().name() == null; } - - @Override - public String getCachedLeaveMessage() { - return cachedLeaveMessage; - } - @Override - public void setCachedLeaveMessage(String cachedLeaveMessage) { - this.cachedLeaveMessage = cachedLeaveMessage; - } - - @Override - public boolean isDisconnecting() { - return disconnecting; - } - - @Override - public void setDisconnecting() { - this.disconnecting = true; - } - - @Override - public boolean getPremiumVanishHidden() { - return premiumVanishHidden; - } - - @Override - public void setPremiumVanishHidden(boolean premiumVanishHidden) { - this.premiumVanishHidden = premiumVanishHidden; - } - - @Override - public int getPremiumVanishUseLevel() { - return pvUseLevel; - } - @Override - public void setPremiumVanishUseLevel(int pvUseLevel) { - this.pvUseLevel = pvUseLevel; - } - - @Override - public int getPremiumVanishSeeLevel() { - return pvSeeLevel; - } - @Override - public void setPremiumVanishSeeLevel(int pvSeeLevel) { - this.pvSeeLevel = pvSeeLevel; - } } From 9a94b17be82a3789f1ee425cab8e57dc8964d85b Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 14:25:21 -0400 Subject: [PATCH 12/15] Brings Auidence field to CorePlayer Missed in #58. --- .../bungee/abstraction/BungeePlayer.java | 15 +++++---------- .../common/abstraction/CorePlayer.java | 6 +++--- .../velocity/abstraction/VelocityPlayer.java | 16 ++++++---------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java index 32117ed..ca87008 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/bungee/abstraction/BungeePlayer.java @@ -1,6 +1,5 @@ package xyz.earthcow.networkjoinmessages.bungee.abstraction; -import net.kyori.adventure.audience.Audience; import net.kyori.adventure.text.Component; import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.connection.Server; @@ -14,12 +13,13 @@ public class BungeePlayer extends CorePlayer { private final ProxiedPlayer bungeePlayer; - private final Audience audience; public BungeePlayer(ProxiedPlayer bungeePlayer) { - super(new BungeeServer(bungeePlayer.getServer().getInfo())); + super( + new BungeeServer(bungeePlayer.getServer().getInfo()), + BungeeMain.getInstance().getAudiences().player(bungeePlayer) + ); this.bungeePlayer = bungeePlayer; - this.audience = BungeeMain.getInstance().getAudiences().player(bungeePlayer); } @Override @@ -29,7 +29,7 @@ public String getName() { @Override public void sendMessage(Component component) { - audience.sendMessage(component); + getAudience().sendMessage(component); } @Override @@ -57,11 +57,6 @@ public int getConnectionIdentity() { return new BungeeServer(server.getInfo()); } - @Override - public @NotNull Audience getAudience() { - return audience; - } - @Override public boolean isInLimbo() { return false; diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java index e2fd5a5..59a0600 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/abstraction/CorePlayer.java @@ -14,12 +14,14 @@ public abstract class CorePlayer implements CoreCommandSender { private CoreBackendServer lastKnownConnectedServer; private boolean disconnecting = false; private String cachedLeaveMessage; + private Audience audience; private boolean premiumVanishHidden = false; private int premiumVanishUseLevel = 0; private int premiumVanishSeeLevel = 0; - public CorePlayer(CoreBackendServer lastKnownConnectedServer) { + public CorePlayer(CoreBackendServer lastKnownConnectedServer, Audience audience) { this.lastKnownConnectedServer = lastKnownConnectedServer; + this.audience = audience; } // Abstract @@ -28,7 +30,5 @@ public CorePlayer(CoreBackendServer lastKnownConnectedServer) { public abstract int getConnectionIdentity(); @Nullable public abstract CoreBackendServer getCurrentServer(); - @NotNull - public abstract Audience getAudience(); public abstract boolean isInLimbo(); } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java b/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java index 34e5b1c..84e9005 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/velocity/abstraction/VelocityPlayer.java @@ -15,14 +15,15 @@ public class VelocityPlayer extends CorePlayer { private final Player velocityPlayer; - private final Audience audience; public VelocityPlayer(Player velocityPlayer) { - super(velocityPlayer.getCurrentServer().isPresent() ? - new VelocityServer(velocityPlayer.getCurrentServer().get().getServer()) - : null); + super( + velocityPlayer.getCurrentServer().isPresent() ? + new VelocityServer(velocityPlayer.getCurrentServer().get().getServer()) + : null + , Audience.audience(velocityPlayer) + ); this.velocityPlayer = velocityPlayer; - this.audience = Audience.audience(velocityPlayer); } @Override @@ -60,11 +61,6 @@ public int getConnectionIdentity() { return new VelocityServer(serverConnection.getServer()); } - @Override - public @NotNull Audience getAudience() { - return audience; - } - @Override public boolean isInLimbo() { if (!VelocityMain.getInstance().getIsLimboAPIAvailable()) { From 2d127ed59251388c09c000756524308e3ee7b04c Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 14:43:20 -0400 Subject: [PATCH 13/15] Corrects silent vanish level logic --- .../networkjoinmessages/common/broadcast/ReceiverResolver.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java index 0f13570..112481a 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolver.java @@ -112,8 +112,7 @@ public boolean isSilentReceiver(@NotNull CorePlayer player, @NotNull CorePlayer return true; if (hasPremiumVanish && config.isPVNotifyVanishEnabledPlayersOnSilentMove()) { if (config.isPVNotifyRespectVanishLevels()) { - if (player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel()) - return true; + return player.getPremiumVanishSeeLevel() >= triggerPlayer.getPremiumVanishUseLevel(); } return (player.hasPermission("pv.use") || player.hasPermission("pv.see")); } From 26406c400d33b51abcb7b07b0b1cd5162c0e2bde Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 14:44:32 -0400 Subject: [PATCH 14/15] Use proper parse target for sendSilentConsoleMessage --- .../earthcow/networkjoinmessages/common/MessageHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java index 7b5ab16..d648b95 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/MessageHandler.java @@ -132,10 +132,10 @@ public void broadcastSilentMessage( @NotNull CorePlayer triggerPlayer, boolean isParseTarget ) { - sendSilentConsoleMessage(type, from, to, triggerPlayer); - CorePlayer parseTarget = isParseTarget ? triggerPlayer : null; + sendSilentConsoleMessage(type, from, to, parseTarget); + for (CorePlayer player : plugin.getAllPlayers()) { if (!receiverResolver.isSilentReceiver(player, triggerPlayer)) continue; sendMessage(player, config.getSilentPrefix() + text, parseTarget); From ed385c19e0068b2e6ad037f2a38b3c385717ba44 Mon Sep 17 00:00:00 2001 From: EarthCow <56940983+EarthCow@users.noreply.github.com> Date: Thu, 14 May 2026 15:27:04 -0400 Subject: [PATCH 15/15] Adds several test files broadcast (59): MessageFormatterTest, ReceiverResolverTest player (57): LeaveJoinBufferManagerTest, PlayerStateStoreTest, SilenceCheckerTest storage (42): TextPlayerJoinTrackerTest, (modifies H2PlayerJoinTrackerTest) util (80): LegacyColorTranslatorTest, PremiumVanishLevelUtilTest --- build.gradle.kts | 1 + .../broadcast/MessageFormatterTest.java | 314 ++++++++++++ .../broadcast/ReceiverResolverTest.java | 456 ++++++++++++++++++ .../player/LeaveJoinBufferManagerTest.java | 230 +++++++++ .../common/player/PlayerStateStoreTest.java | 361 ++++++++++++++ .../common/player/SilenceCheckerTest.java | 229 +++++++++ .../storage/H2PlayerJoinTrackerTest.java | 176 ++++--- .../storage/TextPlayerJoinTrackerTest.java | 250 ++++++++++ .../util/LegacyColorTranslatorTest.java | 196 ++++++++ .../util/PremiumVanishLevelUtilTest.java | 143 ++++++ 10 files changed, 2301 insertions(+), 55 deletions(-) create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/MessageFormatterTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolverTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/player/LeaveJoinBufferManagerTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/player/PlayerStateStoreTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/player/SilenceCheckerTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTrackerTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/util/LegacyColorTranslatorTest.java create mode 100644 src/test/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtilTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 96479d6..89519e8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { testImplementation("org.mockito:mockito-core:5.19.0") testImplementation("org.mockito:mockito-junit-jupiter:5.19.0") + testImplementation("org.sayandev:sayanvanish-api:1.7.0-SNAPSHOT") testImplementation("net.luckperms:api:5.4") testImplementation("net.kyori:adventure-text-serializer-plain:$adventureVersion") testImplementation("io.github.miniplaceholders:miniplaceholders-api:3.0.1") diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/MessageFormatterTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/MessageFormatterTest.java new file mode 100644 index 0000000..1ec019c --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/MessageFormatterTest.java @@ -0,0 +1,314 @@ +package xyz.earthcow.networkjoinmessages.common.broadcast; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreBackendServer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlugin; +import xyz.earthcow.networkjoinmessages.common.abstraction.PremiumVanish; +import xyz.earthcow.networkjoinmessages.common.config.PluginConfig; +import xyz.earthcow.networkjoinmessages.common.modules.SayanVanishHook; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class MessageFormatterTest { + + @Mock private CorePlugin plugin; + @Mock private PluginConfig config; + @Mock private CorePlayer player; + @Mock private CoreBackendServer server; + @Mock private SayanVanishHook sayanVanish; + @Mock private PremiumVanish premiumVanish; + + private final UUID playerUuid = UUID.randomUUID(); + + // A second online player used for counting tests + @Mock private CorePlayer otherPlayer; + private final UUID otherUuid = UUID.randomUUID(); + + @BeforeEach + void setup() { + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getCurrentServer()).thenReturn(server); + when(server.getName()).thenReturn("lobby"); + when(server.getPlayersConnected()).thenReturn(List.of(player)); + + when(otherPlayer.getUniqueId()).thenReturn(otherUuid); + + when(plugin.getServer("lobby")).thenReturn(server); + when(plugin.getAllPlayers()).thenReturn(List.of(player)); + + // Disable vanish integrations by default + when(config.isPVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(config.isSVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(plugin.getVanishAPI()).thenReturn(null); + } + + // ----------------------------------------------------------------------- + // formatJoinMessage + // ----------------------------------------------------------------------- + + @Test + void formatJoinMessage_noPlaceholders_returnsRaw() { + when(config.getJoinNetworkMessage()).thenReturn("Welcome!"); + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + assertEquals("Welcome!", fmt.formatJoinMessage(player)); + } + + @Test + void formatJoinMessage_playerCountServer_playerAlreadyPresent_joining() { + // Player is in the server list. Joining means count is used as-is (player IS already counted). + when(config.getJoinNetworkMessage()).thenReturn("%playercount_server% players online"); + when(server.getPlayersConnected()).thenReturn(List.of(player)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // 1 player on server, not leaving => count stays 1 + assertEquals("1 players online", fmt.formatJoinMessage(player)); + } + + @Test + void formatJoinMessage_playerCountNetwork() { + when(config.getJoinNetworkMessage()).thenReturn("%playercount_network% online"); + when(plugin.getAllPlayers()).thenReturn(List.of(player)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + assertEquals("1 online", fmt.formatJoinMessage(player)); + } + + @Test + void formatJoinMessage_playerNotYetInList_countIsIncrementedByOne() { + // Simulates the state just before the player is added to the server list + when(config.getJoinNetworkMessage()).thenReturn("%playercount_server% players"); + when(server.getPlayersConnected()).thenReturn(Collections.emptyList()); // player not yet counted + when(plugin.getServer("lobby")).thenReturn(server); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // Player absent + !leaving => count should be 0+1 = 1 + assertEquals("1 players", fmt.formatJoinMessage(player)); + } + + // ----------------------------------------------------------------------- + // formatLeaveMessage + // ----------------------------------------------------------------------- + + @Test + void formatLeaveMessage_playerCountServer_leavingReducesCountByOne() { + when(config.getLeaveNetworkMessage()).thenReturn("%playercount_server% remain"); + when(server.getPlayersConnected()).thenReturn(List.of(player)); // still in list at leave time + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // Player present + leaving => 1-1 = 0 + assertEquals("0 remain", fmt.formatLeaveMessage(player)); + } + + @Test + void formatLeaveMessage_noPlaceholders_returnsRaw() { + when(config.getLeaveNetworkMessage()).thenReturn("Goodbye!"); + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + assertEquals("Goodbye!", fmt.formatLeaveMessage(player)); + } + + // ----------------------------------------------------------------------- + // formatFirstJoinMessage + // ----------------------------------------------------------------------- + + @Test + void formatFirstJoinMessage_playerCountServer_delegatesToJoinLogic() { + when(config.getFirstJoinNetworkMessage()).thenReturn("%playercount_server% players"); + when(server.getPlayersConnected()).thenReturn(Collections.emptyList()); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // Player absent, joining => 0+1 = 1 + assertEquals("1 players", fmt.formatFirstJoinMessage(player)); + } + + // ----------------------------------------------------------------------- + // formatSwapMessage + // ----------------------------------------------------------------------- + + @Test + void formatSwapMessage_serverNamePlaceholders() { + when(config.getSwapServerMessage()).thenReturn("%from% -> %to%"); + when(config.getServerDisplayName("lobby")).thenReturn("Lobby"); + when(config.getServerDisplayName("survival")).thenReturn("Survival"); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + assertEquals("Lobby -> Survival", fmt.formatSwapMessage(player, "lobby", "survival")); + } + + @Test + void formatSwapMessage_cleanServerNamePlaceholders() { + when(config.getSwapServerMessage()).thenReturn("%from_clean% to %to_clean%"); + when(config.getServerDisplayName(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + assertEquals("lobby to survival", fmt.formatSwapMessage(player, "lobby", "survival")); + } + + @Test + void formatSwapMessage_playerCountFromPlaceholder() { + when(config.getSwapServerMessage()).thenReturn("%playercount_from% leaving"); + when(config.getServerDisplayName(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + CoreBackendServer fromServer = mock(CoreBackendServer.class); + when(fromServer.getPlayersConnected()).thenReturn(List.of(player)); + when(plugin.getServer("lobby")).thenReturn(fromServer); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // player IS in fromServer list, leaving => 1-1 = 0 + assertEquals("0 leaving", fmt.formatSwapMessage(player, "lobby", "survival")); + } + + @Test + void formatSwapMessage_playerCountToPlaceholder() { + when(config.getSwapServerMessage()).thenReturn("%playercount_to% in dest"); + when(config.getServerDisplayName(anyString())).thenAnswer(inv -> inv.getArgument(0)); + + CoreBackendServer toServer = mock(CoreBackendServer.class); + when(toServer.getPlayersConnected()).thenReturn(Collections.emptyList()); + when(plugin.getServer("survival")).thenReturn(toServer); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // player NOT in toServer, not leaving => 0+1 = 1 + assertEquals("1 in dest", fmt.formatSwapMessage(player, "lobby", "survival")); + } + + @Test + void formatSwapMessage_networkCountPlaceholder() { + when(config.getSwapServerMessage()).thenReturn("%playercount_network% total"); + when(config.getServerDisplayName(anyString())).thenAnswer(inv -> inv.getArgument(0)); + when(plugin.getAllPlayers()).thenReturn(List.of(player, otherPlayer)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // 2 players, player is present and NOT leaving => count = 2 + assertEquals("2 total", fmt.formatSwapMessage(player, "lobby", "survival")); + } + + // ----------------------------------------------------------------------- + // computePlayerCount -- vanish integration + // ----------------------------------------------------------------------- + + @Test + void playerCount_vanishedPlayerIsExcludedWhenPVEnabled() { + when(config.getJoinNetworkMessage()).thenReturn("%playercount_network% online"); + when(config.isPVRemoveVanishedPlayersFromPlayerCount()).thenReturn(true); + when(plugin.getVanishAPI()).thenReturn(premiumVanish); + // Both players online, but otherPlayer is vanished + when(plugin.getAllPlayers()).thenReturn(List.of(player, otherPlayer)); + when(premiumVanish.getInvisiblePlayers()).thenReturn(List.of(otherUuid)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // Only player is visible; player not present in server list, joining => 0+1 = 1 + assertEquals("1 online", fmt.formatJoinMessage(player)); + } + + @Test + void playerCount_vanishedPlayerIsExcludedWhenSVEnabled() { + when(config.getJoinNetworkMessage()).thenReturn("%playercount_network% online"); + when(config.isSVRemoveVanishedPlayersFromPlayerCount()).thenReturn(true); + when(plugin.getAllPlayers()).thenReturn(List.of(player, otherPlayer)); + when(sayanVanish.getVanishedPlayers()).thenReturn(List.of(otherUuid)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, sayanVanish); + // player joining (absent from list), other is vanished + when(plugin.getAllPlayers()).thenReturn(List.of(otherPlayer)); + // only otherPlayer in list, otherPlayer is vanished => visible = 0; subject absent, joining => 1 + assertEquals("1 online", fmt.formatJoinMessage(player)); + } + + @Test + void playerCount_vanishedSubjectIsNotCountedForOthers() { + when(config.getJoinNetworkMessage()).thenReturn("%playercount_network% online"); + when(config.isPVRemoveVanishedPlayersFromPlayerCount()).thenReturn(true); + when(plugin.getVanishAPI()).thenReturn(premiumVanish); + when(plugin.getAllPlayers()).thenReturn(List.of(player)); + // Subject (player) is vanished + when(premiumVanish.getInvisiblePlayers()).thenReturn(List.of(playerUuid)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + // player is vanished => visible list is empty, and subject is vanished so no +1 => 0 + assertEquals("0 online", fmt.formatJoinMessage(player)); + } + + // ----------------------------------------------------------------------- + // getServerPlayerCount -- null server + // ----------------------------------------------------------------------- + + @Test + void getServerPlayerCount_nullServer_leavingReturnsZero() { + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + String result = fmt.getServerPlayerCount( + (CoreBackendServer) null, true, player); + assertEquals("0", result); + } + + @Test + void getServerPlayerCount_nullServer_joiningReturnsOne() { + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + String result = fmt.getServerPlayerCount( + (CoreBackendServer) null, false, player); + assertEquals("1", result); + } + + // ----------------------------------------------------------------------- + // prepareDiscordJoinLeaveTemplate + // ----------------------------------------------------------------------- + + @Test + void prepareDiscordJoinLeaveTemplate_replacesAvatarUrl() { + when(config.isPVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(config.isSVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(plugin.getVanishAPI()).thenReturn(null); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + String result = fmt.prepareDiscordJoinLeaveTemplate( + "Avatar: %embedavatarurl%", player, false, "https://example.com/avatar.png"); + assertEquals("Avatar: https://example.com/avatar.png", result); + } + + @Test + void prepareDiscordJoinLeaveTemplate_replacesPlayerCountServer() { + when(config.isPVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(config.isSVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(plugin.getVanishAPI()).thenReturn(null); + when(server.getPlayersConnected()).thenReturn(List.of(player)); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + String result = fmt.prepareDiscordJoinLeaveTemplate( + "%playercount_server% online", player, false, ""); + // player present, joining (not leaving): 1 + assertEquals("1 online", result); + } + + // ----------------------------------------------------------------------- + // prepareDiscordSwapTemplate + // ----------------------------------------------------------------------- + + @Test + void prepareDiscordSwapTemplate_replacesAllServerPlaceholders() { + when(config.getServerDisplayName("lobby")).thenReturn("Lobby"); + when(config.getServerDisplayName("survival")).thenReturn("Survival"); + when(config.isPVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(config.isSVRemoveVanishedPlayersFromPlayerCount()).thenReturn(false); + when(plugin.getVanishAPI()).thenReturn(null); + + MessageFormatter fmt = new MessageFormatter(plugin, config, null); + String result = fmt.prepareDiscordSwapTemplate( + "%from% -> %to% (%from_clean% to %to_clean%) %embedavatarurl%", + player, "lobby", "survival", "http://img"); + + assertEquals("Lobby -> Survival (lobby to survival) http://img", result); + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolverTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolverTest.java new file mode 100644 index 0000000..8ed98ac --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/broadcast/ReceiverResolverTest.java @@ -0,0 +1,456 @@ +package xyz.earthcow.networkjoinmessages.common.broadcast; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import xyz.earthcow.networkjoinmessages.common.MessageType; +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreBackendServer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlugin; +import xyz.earthcow.networkjoinmessages.common.config.PluginConfig; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ReceiverResolverTest { + + @Mock private CorePlugin plugin; + @Mock private PluginConfig config; + @Mock private CoreLogger logger; + @Mock private CorePlayer lobbyPlayer; + @Mock private CorePlayer survivalPlayer; + @Mock private CorePlayer hubPlayer; + @Mock private CoreBackendServer lobbyServer; + @Mock private CoreBackendServer survivalServer; + @Mock private CoreBackendServer hubServer; + + private final UUID lobbyUuid = UUID.randomUUID(); + private final UUID survivalUuid = UUID.randomUUID(); + private final UUID hubUuid = UUID.randomUUID(); + + @BeforeEach + void setup() { + when(plugin.getCoreLogger()).thenReturn(logger); + + when(lobbyPlayer.getUniqueId()).thenReturn(lobbyUuid); + when(survivalPlayer.getUniqueId()).thenReturn(survivalUuid); + when(hubPlayer.getUniqueId()).thenReturn(hubUuid); + + when(lobbyPlayer.getCurrentServer()).thenReturn(lobbyServer); + when(survivalPlayer.getCurrentServer()).thenReturn(survivalServer); + when(hubPlayer.getCurrentServer()).thenReturn(hubServer); + + when(lobbyServer.getName()).thenReturn("lobby"); + when(survivalServer.getName()).thenReturn("survival"); + when(hubServer.getName()).thenReturn("hub"); + + when(lobbyServer.getPlayersConnected()).thenReturn(List.of(lobbyPlayer)); + when(survivalServer.getPlayersConnected()).thenReturn(List.of(survivalPlayer)); + when(hubServer.getPlayersConnected()).thenReturn(List.of(hubPlayer)); + + when(plugin.getServer("lobby")).thenReturn(lobbyServer); + when(plugin.getServer("survival")).thenReturn(survivalServer); + when(plugin.getServer("hub")).thenReturn(hubServer); + when(plugin.getAllPlayers()).thenReturn(List.of(lobbyPlayer, survivalPlayer, hubPlayer)); + + // Default: no blacklisted servers, blacklist mode + when(config.getBlacklistedServers()).thenReturn(Collections.emptyList()); + when(config.isUseBlacklistAsWhitelist()).thenReturn(false); + when(config.getSwapServerMessageRequires()).thenReturn("ANY"); + + // Default suppression lists empty + when(config.getServerFirstJoinMessageDisabled()).thenReturn(Collections.emptyList()); + when(config.getServerJoinMessageDisabled()).thenReturn(Collections.emptyList()); + when(config.getServerLeaveMessageDisabled()).thenReturn(Collections.emptyList()); + } + + // ----------------------------------------------------------------------- + // getJoinReceivers -- viewableByJoined / viewableByOther combinations + // ----------------------------------------------------------------------- + + @Test + void getJoinReceivers_allFlagsTrue_returnsAllPlayers() { + when(config.isJoinViewableByJoined()).thenReturn(true); + when(config.isJoinViewableByOther()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getJoinReceivers("lobby"); + + assertEquals(3, receivers.size()); + } + + @Test + void getJoinReceivers_onlyJoinedCanSee_returnsLobbyPlayersOnly() { + when(config.isJoinViewableByJoined()).thenReturn(true); + when(config.isJoinViewableByOther()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getJoinReceivers("lobby"); + + assertEquals(1, receivers.size()); + assertTrue(receivers.contains(lobbyPlayer)); + } + + @Test + void getJoinReceivers_onlyOthersCanSee_excludesLobbyPlayers() { + when(config.isJoinViewableByJoined()).thenReturn(false); + when(config.isJoinViewableByOther()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getJoinReceivers("lobby"); + + assertFalse(receivers.contains(lobbyPlayer)); + assertTrue(receivers.contains(survivalPlayer)); + assertTrue(receivers.contains(hubPlayer)); + } + + @Test + void getJoinReceivers_allFlagsFalse_returnsEmpty() { + when(config.isJoinViewableByJoined()).thenReturn(false); + when(config.isJoinViewableByOther()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getJoinReceivers("lobby"); + + assertTrue(receivers.isEmpty()); + } + + // ----------------------------------------------------------------------- + // getLeaveReceivers + // ----------------------------------------------------------------------- + + @Test + void getLeaveReceivers_viewableByLeftOnly_returnsOriginServer() { + when(config.isLeaveViewableByLeft()).thenReturn(true); + when(config.isLeaveViewableByOther()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getLeaveReceivers("lobby"); + + assertEquals(1, receivers.size()); + assertTrue(receivers.contains(lobbyPlayer)); + } + + @Test + void getLeaveReceivers_viewableByOtherOnly_excludesOriginServer() { + when(config.isLeaveViewableByLeft()).thenReturn(false); + when(config.isLeaveViewableByOther()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getLeaveReceivers("lobby"); + + assertFalse(receivers.contains(lobbyPlayer)); + assertTrue(receivers.contains(survivalPlayer)); + } + + // ----------------------------------------------------------------------- + // getSwapReceivers + // ----------------------------------------------------------------------- + + @Test + void getSwapReceivers_allFlagsTrue_returnsAllPlayers() { + when(config.isSwapViewableByJoined()).thenReturn(true); + when(config.isSwapViewableByLeft()).thenReturn(true); + when(config.isSwapViewableByOther()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getSwapReceivers("survival", "lobby"); + + assertEquals(3, receivers.size()); + } + + @Test + void getSwapReceivers_joinedAndLeftOnly_returnsOriginAndDestination() { + when(config.isSwapViewableByJoined()).thenReturn(true); + when(config.isSwapViewableByLeft()).thenReturn(true); + when(config.isSwapViewableByOther()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getSwapReceivers("survival", "lobby"); + + assertTrue(receivers.contains(survivalPlayer)); + assertTrue(receivers.contains(lobbyPlayer)); + assertFalse(receivers.contains(hubPlayer)); + } + + @Test + void getSwapReceivers_othersOnly_excludesBothEndpoints() { + when(config.isSwapViewableByJoined()).thenReturn(false); + when(config.isSwapViewableByLeft()).thenReturn(false); + when(config.isSwapViewableByOther()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getSwapReceivers("survival", "lobby"); + + assertFalse(receivers.contains(survivalPlayer)); + assertFalse(receivers.contains(lobbyPlayer)); + assertTrue(receivers.contains(hubPlayer)); + } + + // ----------------------------------------------------------------------- + // getFirstJoinReceivers + // ----------------------------------------------------------------------- + + @Test + void getFirstJoinReceivers_joinedAndOther_returnsAll() { + when(config.isFirstJoinViewableByJoined()).thenReturn(true); + when(config.isFirstJoinViewableByOther()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + List receivers = resolver.getFirstJoinReceivers("lobby"); + + assertEquals(3, receivers.size()); + } + + // ----------------------------------------------------------------------- + // isBlacklisted(CorePlayer) -- blacklist mode + // ----------------------------------------------------------------------- + + @Test + void isBlacklisted_blacklistMode_serverIsListed_returnsTrue() { + when(config.getBlacklistedServers()).thenReturn(List.of("lobby")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertTrue(resolver.isBlacklisted(lobbyPlayer)); + } + + @Test + void isBlacklisted_blacklistMode_serverNotListed_returnsFalse() { + when(config.getBlacklistedServers()).thenReturn(List.of("lobby")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertFalse(resolver.isBlacklisted(survivalPlayer)); + } + + // ----------------------------------------------------------------------- + // isBlacklisted(CorePlayer) -- whitelist mode + // ----------------------------------------------------------------------- + + @Test + void isBlacklisted_whitelistMode_serverIsListed_returnsFalse() { + when(config.getBlacklistedServers()).thenReturn(List.of("lobby")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + // In whitelist mode: listed = allowed = NOT blacklisted + assertFalse(resolver.isBlacklisted(lobbyPlayer)); + } + + @Test + void isBlacklisted_whitelistMode_serverNotListed_returnsTrue() { + when(config.getBlacklistedServers()).thenReturn(List.of("lobby")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + // survival is NOT listed => not in whitelist => blacklisted + assertTrue(resolver.isBlacklisted(survivalPlayer)); + } + + // ----------------------------------------------------------------------- + // isBlacklisted(from, to) -- swap requires modes + // ----------------------------------------------------------------------- + + @ParameterizedTest(name = "requires={0}, fromListed={1}, toListed={2}, whitelistMode={3}, expected={4}") + @CsvSource({ + // BLACKLIST mode + "ANY, true, false, false, true", // from listed => blocked + "ANY, false, true, false, true", // to listed => blocked + "ANY, false, false, false, false", // neither listed => not blocked + "BOTH, true, false, false, false", // only from listed, need both => not blocked + "BOTH, true, true, false, true", // both listed => blocked + "JOINED, false, true, false, true", // to (joined) listed => blocked + "JOINED, true, false, false, false",// only from listed => not blocked for JOINED + "LEFT, true, false, false, true", // from (left) listed => blocked + "LEFT, false, true, false, false", // only to listed => not blocked for LEFT + // WHITELIST mode + "ANY, false, false, true, true", // neither in whitelist => blocked + "ANY, true, true, true, false", // both in whitelist => not blocked (ANY: from||to listed = true; whitelist XOR => false) + }) + void isBlacklisted_swap_requiresModes( + String requires, boolean fromListed, boolean toListed, + boolean whitelistMode, boolean expected) { + + String from = fromListed ? "lobby" : "hub"; + String to = toListed ? "survival" : "other"; + + // Configure listed servers: always include lobby and survival in the list + when(config.getBlacklistedServers()).thenReturn(List.of("lobby", "survival")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(whitelistMode); + when(config.getSwapServerMessageRequires()).thenReturn(requires); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertEquals(expected, resolver.isBlacklisted(from, to)); + } + + @Test + void isBlacklisted_swap_unknownRequiresValue_returnsFalseInBlacklistMode() { + when(config.getBlacklistedServers()).thenReturn(List.of("lobby")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(false); + when(config.getSwapServerMessageRequires()).thenReturn("GARBAGE"); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + // result = XOR(false, false) = false... but in whitelist mode it would be true + // In blacklist mode: isUseBlacklistAsWhitelist=false XOR result=false => false + assertFalse(resolver.isBlacklisted("lobby", "survival")); + } + + @Test + void isBlacklisted_swap_nullServers_neverListed() { + when(config.getBlacklistedServers()).thenReturn(List.of("lobby")); + when(config.isUseBlacklistAsWhitelist()).thenReturn(false); + when(config.getSwapServerMessageRequires()).thenReturn("ANY"); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertFalse(resolver.isBlacklisted(null, null)); + } + + // ----------------------------------------------------------------------- + // getServerSuppressedPlayers + // ----------------------------------------------------------------------- + + @Test + void getServerSuppressedPlayers_joinType_returnsPlayersOnDisabledServers() { + when(config.getServerJoinMessageDisabled()).thenReturn(List.of("lobby")); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + Set suppressed = resolver.getServerSuppressedPlayers(MessageType.JOIN); + + assertTrue(suppressed.contains(lobbyUuid)); + assertFalse(suppressed.contains(survivalUuid)); + } + + @Test + void getServerSuppressedPlayers_leaveType_returnsPlayersOnDisabledServers() { + when(config.getServerLeaveMessageDisabled()).thenReturn(List.of("survival")); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + Set suppressed = resolver.getServerSuppressedPlayers(MessageType.LEAVE); + + assertTrue(suppressed.contains(survivalUuid)); + assertFalse(suppressed.contains(lobbyUuid)); + } + + @Test + void getServerSuppressedPlayers_firstJoinType_usesFirstJoinList() { + when(config.getServerFirstJoinMessageDisabled()).thenReturn(List.of("hub")); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + Set suppressed = resolver.getServerSuppressedPlayers(MessageType.FIRST_JOIN); + + assertTrue(suppressed.contains(hubUuid)); + assertFalse(suppressed.contains(lobbyUuid)); + } + + @Test + void getServerSuppressedPlayers_swapType_alwaysEmpty() { + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + Set suppressed = resolver.getServerSuppressedPlayers(MessageType.SWAP); + assertTrue(suppressed.isEmpty()); + } + + @Test + void getServerSuppressedPlayers_unknownServer_isSkipped() { + when(config.getServerJoinMessageDisabled()).thenReturn(List.of("nonexistent")); + when(plugin.getServer("nonexistent")).thenReturn(null); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + Set suppressed = resolver.getServerSuppressedPlayers(MessageType.JOIN); + assertTrue(suppressed.isEmpty()); + } + + // ----------------------------------------------------------------------- + // isSilentReceiver + // ----------------------------------------------------------------------- + + @Test + void isSilentReceiver_adminPermission_returnsTrue() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(true); + when(lobbyPlayer.hasPermission("networkjoinmessages.silent")).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertTrue(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } + + @Test + void isSilentReceiver_adminPermDisabled_returnsFalse() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(false); + when(lobbyPlayer.hasPermission("networkjoinmessages.silent")).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertFalse(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } + + @Test + void isSilentReceiver_sayanVanishEnabled_vanishPermission_returnsTrue() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(false); + when(config.isSVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(true); + when(lobbyPlayer.hasPermission("sayanvanish.vanish.use")).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, true, false); + assertTrue(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } + + @Test + void isSilentReceiver_pvEnabled_noRespectLevels_pvUsePerm_returnsTrue() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(false); + when(config.isSVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(false); + when(config.isPVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(true); + when(config.isPVNotifyRespectVanishLevels()).thenReturn(false); + when(lobbyPlayer.hasPermission("pv.use")).thenReturn(true); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, true); + assertTrue(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } + + @Test + void isSilentReceiver_pvEnabled_respectLevels_sufficientSeeLevel_returnsTrue() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(false); + when(config.isPVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(true); + when(config.isPVNotifyRespectVanishLevels()).thenReturn(true); + // observer see level 5, trigger player use level 5 => observer qualifies + when(lobbyPlayer.getPremiumVanishSeeLevel()).thenReturn(5); + when(survivalPlayer.getPremiumVanishUseLevel()).thenReturn(5); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, true); + assertTrue(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } + + @Test + void isSilentReceiver_pvEnabled_respectLevels_insufficientSeeLevel_returnsFalse() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(false); + when(config.isPVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(true); + when(config.isPVNotifyRespectVanishLevels()).thenReturn(true); + // observer see level 3 < trigger use level 5 => does not qualify + when(lobbyPlayer.getPremiumVanishSeeLevel()).thenReturn(3); + when(survivalPlayer.getPremiumVanishUseLevel()).thenReturn(5); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, true); + assertFalse(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } + + @Test + void isSilentReceiver_noConditionsMet_returnsFalse() { + when(config.isNotifyAdminsOnSilentMove()).thenReturn(false); + when(config.isSVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(false); + when(config.isPVNotifyVanishEnabledPlayersOnSilentMove()).thenReturn(false); + + ReceiverResolver resolver = new ReceiverResolver(plugin, config, false, false); + assertFalse(resolver.isSilentReceiver(lobbyPlayer, survivalPlayer)); + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/player/LeaveJoinBufferManagerTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/player/LeaveJoinBufferManagerTest.java new file mode 100644 index 0000000..be805b8 --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/player/LeaveJoinBufferManagerTest.java @@ -0,0 +1,230 @@ +package xyz.earthcow.networkjoinmessages.common.player; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlugin; +import xyz.earthcow.networkjoinmessages.common.config.PluginConfig; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class LeaveJoinBufferManagerTest { + + @Mock private CorePlugin plugin; + @Mock private CoreLogger logger; + @Mock private PluginConfig config; + @Mock private CorePlayer player; + + private final UUID playerUuid = UUID.randomUUID(); + + @BeforeEach + void setup() { + when(plugin.getCoreLogger()).thenReturn(logger); + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn("TestPlayer"); + } + + // ----------------------------------------------------------------------- + // isDisabled + // ----------------------------------------------------------------------- + + @Test + void isDisabled_zeroDuration_returnsTrue() { + when(config.getLeaveJoinBufferDuration()).thenReturn(0); + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + assertTrue(mgr.isDisabled()); + } + + @Test + void isDisabled_positiveDuration_returnsFalse() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + assertFalse(mgr.isDisabled()); + } + + @Test + void isDisabled_negativeDuration_returnsTrue() { + when(config.getLeaveJoinBufferDuration()).thenReturn(-1); + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + assertTrue(mgr.isDisabled()); + } + + // ----------------------------------------------------------------------- + // isPending -- before any schedule + // ----------------------------------------------------------------------- + + @Test + void isPending_noTaskScheduled_returnsFalse() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + assertFalse(mgr.isPending(player)); + } + + // ----------------------------------------------------------------------- + // scheduleLeave + // ----------------------------------------------------------------------- + + @Test + void scheduleLeave_schedulesAsyncTask() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), eq(5000))).thenReturn(42); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + + verify(plugin).runTaskAsyncLater(any(), eq(5000)); + } + + @Test + void scheduleLeave_afterSchedule_isPendingReturnsTrue() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), anyInt())).thenReturn(42); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + + assertTrue(mgr.isPending(player)); + } + + @Test + void scheduleLeave_whenTaskFires_callbackIsInvoked() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + + // Capture the Runnable so we can fire it manually + ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + when(plugin.runTaskAsyncLater(runnableCaptor.capture(), anyInt())).thenReturn(42); + + AtomicBoolean callbackFired = new AtomicBoolean(false); + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> callbackFired.set(true)); + + // Simulate the scheduler firing + runnableCaptor.getValue().run(); + + assertTrue(callbackFired.get(), "Leave callback should have been invoked"); + } + + @Test + void scheduleLeave_whenTaskFires_pendingEntryIsRemoved() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + + ArgumentCaptor runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + when(plugin.runTaskAsyncLater(runnableCaptor.capture(), anyInt())).thenReturn(42); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + + runnableCaptor.getValue().run(); + + assertFalse(mgr.isPending(player), + "Pending entry should be removed after the task fires"); + } + + // ----------------------------------------------------------------------- + // cancelIfPending + // ----------------------------------------------------------------------- + + @Test + void cancelIfPending_noPendingTask_returnsFalse() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + assertFalse(mgr.cancelIfPending(player)); + } + + @Test + void cancelIfPending_hasPendingTask_returnsTrueAndCancels() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), anyInt())).thenReturn(99); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + + boolean result = mgr.cancelIfPending(player); + + assertTrue(result); + verify(plugin).cancelTask(99); + } + + @Test + void cancelIfPending_hasPendingTask_removesPendingEntry() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), anyInt())).thenReturn(99); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + mgr.cancelIfPending(player); + + assertFalse(mgr.isPending(player)); + } + + @Test + void cancelIfPending_calledTwice_secondCallReturnsFalse() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), anyInt())).thenReturn(99); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + + assertTrue(mgr.cancelIfPending(player)); + assertFalse(mgr.cancelIfPending(player), "Second cancel on same player should return false"); + } + + // ----------------------------------------------------------------------- + // Multiple players -- independence + // ----------------------------------------------------------------------- + + @Test + void scheduleLeave_multiplePlayers_areTrackedIndependently() { + CorePlayer player2 = mock(CorePlayer.class); + UUID uuid2 = UUID.randomUUID(); + when(player2.getUniqueId()).thenReturn(uuid2); + when(player2.getName()).thenReturn("OtherPlayer"); + + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), anyInt())).thenReturn(1, 2); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + mgr.scheduleLeave(player2, () -> {}); + + assertTrue(mgr.isPending(player)); + assertTrue(mgr.isPending(player2)); + + mgr.cancelIfPending(player); + + assertFalse(mgr.isPending(player)); + assertTrue(mgr.isPending(player2), "Cancelling player1 must not affect player2"); + } + + // ----------------------------------------------------------------------- + // scheduleLeave -- second call for same player overwrites first + // ----------------------------------------------------------------------- + + @Test + void scheduleLeave_calledTwiceForSamePlayer_overwritesTaskId() { + when(config.getLeaveJoinBufferDuration()).thenReturn(5000); + when(plugin.runTaskAsyncLater(any(), anyInt())).thenReturn(10, 20); + + LeaveJoinBufferManager mgr = new LeaveJoinBufferManager(plugin, config); + mgr.scheduleLeave(player, () -> {}); + mgr.scheduleLeave(player, () -> {}); // second schedule + + // cancelIfPending should cancel the LAST registered task (20) + mgr.cancelIfPending(player); + verify(plugin).cancelTask(20); + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/player/PlayerStateStoreTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/player/PlayerStateStoreTest.java new file mode 100644 index 0000000..545f7ef --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/player/PlayerStateStoreTest.java @@ -0,0 +1,361 @@ +package xyz.earthcow.networkjoinmessages.common.player; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import xyz.earthcow.networkjoinmessages.common.MessageType; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlugin; +import xyz.earthcow.networkjoinmessages.common.config.PluginConfig; +import xyz.earthcow.networkjoinmessages.common.storage.PlayerDataStore; +import xyz.earthcow.networkjoinmessages.common.util.PlayerDataSnapshot; + +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PlayerStateStoreTest { + + @Mock private CorePlugin plugin; + @Mock private PluginConfig config; + @Mock private PlayerDataStore store; + @Mock private CorePlayer player; + + private final UUID playerUuid = UUID.randomUUID(); + + @BeforeEach + void setup() { + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn("TestPlayer"); + + // Default: all "ignore by default" flags OFF, silent default OFF + when(config.isIgnoreJoinByDefault()).thenReturn(false); + when(config.isIgnoreSwapByDefault()).thenReturn(false); + when(config.isIgnoreLeaveByDefault()).thenReturn(false); + when(config.isSilentJoinDefaultState()).thenReturn(false); + + // Async tasks: execute synchronously for testing + doAnswer(inv -> { ((Runnable) inv.getArgument(0)).run(); return null; }) + .when(plugin).runTaskAsync(any()); + } + + // ----------------------------------------------------------------------- + // loadData -- new player (null snapshot from store) + // ----------------------------------------------------------------------- + + @Test + void loadData_newPlayer_storeReturnsNull_nothingAddedToSuppression() { + when(store.getData(playerUuid)).thenReturn(null); + when(config.isIgnoreJoinByDefault()).thenReturn(false); + when(config.isIgnoreSwapByDefault()).thenReturn(false); + when(config.isIgnoreLeaveByDefault()).thenReturn(false); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + // Messages should NOT be suppressed + assertFalse(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void loadData_newPlayer_ignoreJoinByDefault_suppressesJoin() { + when(store.getData(playerUuid)).thenReturn(null); + when(config.isIgnoreJoinByDefault()).thenReturn(true); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + assertTrue(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + } + + @Test + void loadData_newPlayer_ignoreSwapByDefault_suppressesSwap() { + when(store.getData(playerUuid)).thenReturn(null); + when(config.isIgnoreSwapByDefault()).thenReturn(true); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + assertTrue(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + } + + @Test + void loadData_newPlayer_ignoreLeaveByDefault_suppressesLeave() { + when(store.getData(playerUuid)).thenReturn(null); + when(config.isIgnoreLeaveByDefault()).thenReturn(true); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + assertTrue(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void loadData_newPlayer_savesInitialRecord() { + when(store.getData(playerUuid)).thenReturn(null); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + verify(store).saveData(eq(playerUuid), any(PlayerDataSnapshot.class)); + } + + // ----------------------------------------------------------------------- + // loadData -- returning player with stored snapshot + // ----------------------------------------------------------------------- + + @Test + void loadData_returningPlayer_silentStateRestored() { + PlayerDataSnapshot snapshot = new PlayerDataSnapshot("TestPlayer", true, null, null, null); + when(store.getData(playerUuid)).thenReturn(snapshot); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + // Grant the permission so getSilentState checks the map + when(player.hasPermission("networkjoinmessages.silent")).thenReturn(true); + assertTrue(stateStore.getSilentState(player)); + } + + @Test + void loadData_returningPlayer_nullSilentState_usesDefault() { + PlayerDataSnapshot snapshot = new PlayerDataSnapshot("TestPlayer", null, null, null, null); + when(store.getData(playerUuid)).thenReturn(snapshot); + when(config.isSilentJoinDefaultState()).thenReturn(false); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + when(player.hasPermission("networkjoinmessages.silent")).thenReturn(true); + assertFalse(stateStore.getSilentState(player)); + } + + @Test + void loadData_returningPlayer_explicitIgnoreJoinTrue_suppressesJoin() { + PlayerDataSnapshot snapshot = new PlayerDataSnapshot("TestPlayer", null, true, null, null); + when(store.getData(playerUuid)).thenReturn(snapshot); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + assertTrue(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + } + + @Test + void loadData_returningPlayer_explicitIgnoreJoinFalse_doesNotSuppressEvenWithDefault() { + when(config.isIgnoreJoinByDefault()).thenReturn(true); // default says suppress + PlayerDataSnapshot snapshot = new PlayerDataSnapshot("TestPlayer", null, false, null, null); + when(store.getData(playerUuid)).thenReturn(snapshot); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.loadData(playerUuid, "TestPlayer"); + + // Explicit false overrides the default + assertFalse(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + } + + // ----------------------------------------------------------------------- + // getSilentState -- permission guard + // ----------------------------------------------------------------------- + + @Test + void getSilentState_playerLacksPermission_returnsFalseRegardlessOfStoredState() { + when(player.hasPermission("networkjoinmessages.silent")).thenReturn(false); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + // Even if the map had true stored for this UUID, no permission => false + assertFalse(stateStore.getSilentState(player)); + } + + @Test + void getSilentState_playerHasPermission_firstAccessUsesDefault() { + when(player.hasPermission("networkjoinmessages.silent")).thenReturn(true); + when(config.isSilentJoinDefaultState()).thenReturn(true); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + assertTrue(stateStore.getSilentState(player)); + } + + // ----------------------------------------------------------------------- + // setSilentState + // ----------------------------------------------------------------------- + + @Test + void setSilentState_updatesStateAndPersists() { + when(player.hasPermission("networkjoinmessages.silent")).thenReturn(true); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSilentState(player, true); + + assertTrue(stateStore.getSilentState(player)); + verify(store).saveData(eq(playerUuid), any(PlayerDataSnapshot.class)); + } + + @Test + void setSilentState_falseUpdatesAndPersists() { + when(player.hasPermission("networkjoinmessages.silent")).thenReturn(true); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSilentState(player, true); + stateStore.setSilentState(player, false); + + assertFalse(stateStore.getSilentState(player)); + } + + // ----------------------------------------------------------------------- + // setSendMessageState -- by string type + // ----------------------------------------------------------------------- + + @Test + void setSendMessageState_allFalse_suppressesAllTypes() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("all", player, false); + + assertTrue(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + assertTrue(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + assertTrue(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void setSendMessageState_allTrue_removesAllSuppression() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("all", player, false); // first suppress + stateStore.setSendMessageState("all", player, true); // then re-enable + + assertFalse(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void setSendMessageState_joinOnly_onlyJoinSuppressed() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("join", player, false); + + assertTrue(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void setSendMessageState_leaveOnly_onlyLeaveSuppressed() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("leave", player, false); + + assertFalse(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + assertTrue(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void setSendMessageState_swapOnly_onlySwapSuppressed() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("swap", player, false); + + assertFalse(stateStore.getSuppressedPlayers(MessageType.JOIN).contains(playerUuid)); + assertTrue(stateStore.getSuppressedPlayers(MessageType.SWAP).contains(playerUuid)); + assertFalse(stateStore.getSuppressedPlayers(MessageType.LEAVE).contains(playerUuid)); + } + + @Test + void setSendMessageState_persistsToStore() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("join", player, false); + + verify(store).saveData(eq(playerUuid), any(PlayerDataSnapshot.class)); + } + + // ----------------------------------------------------------------------- + // getSuppressedPlayers -- FIRST_JOIN maps to JOIN set + // ----------------------------------------------------------------------- + + @Test + void getSuppressedPlayers_firstJoinMapsToJoinSet() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setSendMessageState("join", player, false); + + Set joinSuppressed = stateStore.getSuppressedPlayers(MessageType.JOIN); + Set firstJoinSuppressed = stateStore.getSuppressedPlayers(MessageType.FIRST_JOIN); + assertSame(joinSuppressed, firstJoinSuppressed, + "FIRST_JOIN should return the same set as JOIN"); + } + + // ----------------------------------------------------------------------- + // isConnected / setConnected + // ----------------------------------------------------------------------- + + @Test + void setConnected_true_marksPlayerOnline() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setConnected(player, true); + assertTrue(stateStore.isConnected(player)); + } + + @Test + void setConnected_false_marksPlayerOffline() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setConnected(player, true); + stateStore.setConnected(player, false); + assertFalse(stateStore.isConnected(player)); + } + + @Test + void isConnected_unknownPlayer_returnsFalse() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + assertFalse(stateStore.isConnected(player)); + } + + // ----------------------------------------------------------------------- + // getFrom / setFrom + // ----------------------------------------------------------------------- + + @Test + void setFrom_storesServerName() { + // This is only added so that player.getCurrentServer().getName() doesn't throw a NPE + // The only time player#getCurrentServer can possibly be null is the brief time between when they initially + // connect to when the server connected event is fired and CorePlayerListener#handleJoin is run + var mockServer = mock(xyz.earthcow.networkjoinmessages.common.abstraction.CoreBackendServer.class); + when(player.getCurrentServer()).thenReturn(mockServer); + when(mockServer.getName()).thenReturn(""); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + stateStore.setFrom(player, "lobby"); + assertEquals("lobby", stateStore.getFrom(player)); + } + + @Test + void getFrom_noEntryStored_fallsBackToCurrentServer() { + var mockServer = mock(xyz.earthcow.networkjoinmessages.common.abstraction.CoreBackendServer.class); + when(player.getCurrentServer()).thenReturn(mockServer); + when(mockServer.getName()).thenReturn("survival"); + + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, store); + assertEquals("survival", stateStore.getFrom(player)); + } + + // ----------------------------------------------------------------------- + // null store -- no NPE + // ----------------------------------------------------------------------- + + @Test + void loadData_nullStore_doesNotThrow() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, null); + assertDoesNotThrow(() -> stateStore.loadData(playerUuid, "TestPlayer")); + } + + @Test + void setSendMessageState_nullStore_doesNotThrow() { + PlayerStateStore stateStore = new PlayerStateStore(plugin, config, null); + assertDoesNotThrow(() -> stateStore.setSendMessageState("join", player, false)); + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/player/SilenceCheckerTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/player/SilenceCheckerTest.java new file mode 100644 index 0000000..5e70a61 --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/player/SilenceCheckerTest.java @@ -0,0 +1,229 @@ +package xyz.earthcow.networkjoinmessages.common.player; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlugin; +import xyz.earthcow.networkjoinmessages.common.abstraction.PremiumVanish; +import xyz.earthcow.networkjoinmessages.common.config.PluginConfig; +import xyz.earthcow.networkjoinmessages.common.modules.SayanVanishHook; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SilenceCheckerTest { + + @Mock private CorePlugin plugin; + @Mock private CoreLogger logger; + @Mock private PluginConfig config; + @Mock private PlayerStateStore stateStore; + @Mock private SayanVanishHook sayanVanish; + @Mock private PremiumVanish premiumVanish; + @Mock private CorePlayer player; + + private final UUID playerUuid = UUID.randomUUID(); + + @BeforeEach + void setup() { + when(plugin.getCoreLogger()).thenReturn(logger); + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn("TestPlayer"); + + // Default: player not toggled silent, no vanish integrations active + when(stateStore.getSilentState(player)).thenReturn(false); + when(config.isSVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(config.isPVTreatVanishedOnJoin()).thenReturn(false); + when(player.isPremiumVanishHidden()).thenReturn(false); + when(player.hasPermission("pv.joinvanished")).thenReturn(false); + } + + // ----------------------------------------------------------------------- + // isSilent(player) convenience overload + // ----------------------------------------------------------------------- + + @Test + void isSilent_noConditionsMet_returnsFalse() { + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, null); + assertFalse(checker.isSilent(player)); + } + + // ----------------------------------------------------------------------- + // Silent toggle state + // ----------------------------------------------------------------------- + + @Test + void isSilent_toggledSilent_returnsTrue() { + when(stateStore.getSilentState(player)).thenReturn(true); + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, null); + assertTrue(checker.isSilent(player)); + } + + @Test + void isSilent_toggleNotSilent_returnsFalse() { + when(stateStore.getSilentState(player)).thenReturn(false); + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, null); + assertFalse(checker.isSilent(player)); + } + + // ----------------------------------------------------------------------- + // SayanVanish integration + // ----------------------------------------------------------------------- + + @Test + void isSilent_sayanVanishPresent_treatSilentEnabled_playerVanished_returnsTrue() { + when(config.isSVTreatVanishedPlayersAsSilent()).thenReturn(true); + when(sayanVanish.isVanished(player)).thenReturn(true); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, sayanVanish, null); + assertTrue(checker.isSilent(player)); + } + + @Test + void isSilent_sayanVanishPresent_treatSilentEnabled_playerNotVanished_returnsFalse() { + when(config.isSVTreatVanishedPlayersAsSilent()).thenReturn(true); + when(sayanVanish.isVanished(player)).thenReturn(false); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, sayanVanish, null); + assertFalse(checker.isSilent(player)); + } + + @Test + void isSilent_sayanVanishPresent_treatSilentDisabled_playerVanished_returnsFalse() { + when(config.isSVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(sayanVanish.isVanished(player)).thenReturn(true); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, sayanVanish, null); + assertFalse(checker.isSilent(player)); + } + + @Test + void isSilent_sayanVanishNull_vanishedFlagIrrelevant_returnsFalse() { + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, null); + assertFalse(checker.isSilent(player)); + } + + // ----------------------------------------------------------------------- + // PremiumVanish integration -- vanished via API + // ----------------------------------------------------------------------- + + @Test + void isSilent_pvPresent_treatSilentEnabled_playerVanishedByAPI_returnsTrue() { + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(true); + when(premiumVanish.isVanished(playerUuid)).thenReturn(true); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + assertTrue(checker.isSilent(player)); + } + + @Test + void isSilent_pvPresent_treatSilentEnabled_playerNotVanished_returnsFalse() { + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(true); + when(premiumVanish.isVanished(playerUuid)).thenReturn(false); + when(player.isPremiumVanishHidden()).thenReturn(false); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + assertFalse(checker.isSilent(player)); + } + + @Test + void isSilent_pvPresent_treatSilentEnabled_playerHiddenFlag_returnsTrue() { + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(true); + when(premiumVanish.isVanished(playerUuid)).thenReturn(false); + when(player.isPremiumVanishHidden()).thenReturn(true); // hidden flag set from previous join + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + assertTrue(checker.isSilent(player)); + } + + @Test + void isSilent_pvPresent_treatSilentDisabled_playerVanished_returnsFalse() { + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(premiumVanish.isVanished(playerUuid)).thenReturn(true); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + assertFalse(checker.isSilent(player)); + } + + // ----------------------------------------------------------------------- + // PremiumVanish -- TreatVanishedOnJoin + // ----------------------------------------------------------------------- + + @Test + void isSilent_pvTreatVanishedOnJoin_playerHasPerm_setsHiddenAndReturnsSilent() { + when(config.isPVTreatVanishedOnJoin()).thenReturn(true); + when(player.hasPermission("pv.joinvanished")).thenReturn(true); + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(true); + // After setPremiumVanishHidden(true), player.isPremiumVanishHidden() should return true. + // We model this with a doAnswer or just verify the call was made. + when(premiumVanish.isVanished(playerUuid)).thenReturn(false); + when(player.isPremiumVanishHidden()).thenReturn(false); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + // joining=true triggers the PVTreatVanishedOnJoin path + checker.isSilent(player, false, true); + + // Verify that setPremiumVanishHidden(true) was invoked + verify(player).setPremiumVanishHidden(true); + } + + @Test + void isSilent_pvTreatVanishedOnJoin_playerNoPerm_doesNotSetHidden() { + when(config.isPVTreatVanishedOnJoin()).thenReturn(true); + when(player.hasPermission("pv.joinvanished")).thenReturn(false); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + checker.isSilent(player, false, true); + + verify(player, never()).setPremiumVanishHidden(true); + } + + @Test + void isSilent_pvTreatVanishedOnJoin_joiningFalse_doesNotSetHidden() { + when(config.isPVTreatVanishedOnJoin()).thenReturn(true); + when(player.hasPermission("pv.joinvanished")).thenReturn(true); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, null, premiumVanish); + // joining=false => the TreatVanishedOnJoin branch should be skipped + checker.isSilent(player, false, false); + + verify(player, never()).setPremiumVanishHidden(true); + } + + // ----------------------------------------------------------------------- + // Multiple conditions -- OR semantics + // ----------------------------------------------------------------------- + + @Test + void isSilent_multipleConditionsAnyTrue_returnsTrue() { + // toggle=false, SV vanished=false, PV vanished=true + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(true); + when(premiumVanish.isVanished(playerUuid)).thenReturn(true); + when(config.isSVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(sayanVanish.isVanished(player)).thenReturn(false); + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, sayanVanish, premiumVanish); + assertTrue(checker.isSilent(player)); + } + + @Test + void isSilent_allConditionsFalse_returnsFalse() { + when(config.isSVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(config.isPVTreatVanishedPlayersAsSilent()).thenReturn(false); + when(sayanVanish.isVanished(player)).thenReturn(true); // vanished but integration disabled + + SilenceChecker checker = new SilenceChecker(plugin, config, stateStore, sayanVanish, premiumVanish); + assertFalse(checker.isSilent(player)); + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTrackerTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTrackerTest.java index 17f6fbd..95b5501 100644 --- a/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTrackerTest.java +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTrackerTest.java @@ -3,29 +3,24 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.mockito.Mockito; import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; import java.io.File; import java.nio.file.Files; -import java.nio.file.Path; +import java.util.Map; import java.util.UUID; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; class H2PlayerJoinTrackerTest { - @TempDir - private Path tempDir; - private File tempDbFile; private H2PlayerJoinTracker tracker; @BeforeEach - void setUp() throws Exception { - tempDbFile = tempDir.resolve("joined").toFile(); + void setup() throws Exception { + tempDbFile = Files.createTempFile("join-tracker-h2-test", ".mv.db").toFile(); CoreLogger logger = Mockito.mock(CoreLogger.class); tracker = new H2PlayerJoinTracker(logger, tempDbFile.getAbsolutePath()); } @@ -36,83 +31,154 @@ void tearDown() throws Exception { if (tempDbFile.exists()) tempDbFile.delete(); } + // ----------------------------------------------------------------------- + // hasJoined -- baseline + // ----------------------------------------------------------------------- + + @Test + void hasJoined_unknownUuid_returnsFalse() { + assertFalse(tracker.hasJoined(UUID.randomUUID())); + } + + @Test + void hasJoined_afterMarkAsJoined_returnsTrue() { + UUID uuid = UUID.randomUUID(); + tracker.markAsJoined(uuid, "Notch"); + assertTrue(tracker.hasJoined(uuid)); + } + + // ----------------------------------------------------------------------- + // markAsJoined -- idempotency (MERGE semantics) + // ----------------------------------------------------------------------- + @Test - void testNewUUIDHasNotJoined() { + void markAsJoined_calledTwice_noException() { UUID uuid = UUID.randomUUID(); - assertFalse(tracker.hasJoined(uuid), "New UUID should not be marked as joined"); + assertDoesNotThrow(() -> { + tracker.markAsJoined(uuid, "Notch"); + tracker.markAsJoined(uuid, "Notch"); + }); } @Test - void testMarkAsJoined() { + void markAsJoined_calledTwiceSameUuid_appearsOnceInExport() { UUID uuid = UUID.randomUUID(); - assertFalse(tracker.hasJoined(uuid)); - tracker.markAsJoined(uuid, "Player 1"); - assertTrue(tracker.hasJoined(uuid), "UUID should be marked as joined after insert"); + tracker.markAsJoined(uuid, "Notch"); + tracker.markAsJoined(uuid, "NotchRenamed"); + + Map exported = tracker.exportAll(); + assertEquals(1, exported.size(), "MERGE must not create duplicate rows"); } @Test - void testDoubleInsert() { + void markAsJoined_calledTwiceNewName_nameIsUpdated() { UUID uuid = UUID.randomUUID(); - tracker.markAsJoined(uuid, "Player 2"); - tracker.markAsJoined(uuid, "Player 2"); // Should not throw any exceptions - assertTrue(tracker.hasJoined(uuid)); + tracker.markAsJoined(uuid, "OldName"); + tracker.markAsJoined(uuid, "NewName"); + + assertEquals("NewName", tracker.exportAll().get(uuid), + "MERGE should update the player name on second call"); } + // ----------------------------------------------------------------------- + // markAsJoined -- multiple distinct players + // ----------------------------------------------------------------------- + @Test - void testMultipleUUIDs() { + void markAsJoined_multipleDistinctPlayers_allTracked() { UUID uuid1 = UUID.randomUUID(); UUID uuid2 = UUID.randomUUID(); - tracker.markAsJoined(uuid1, "Player 3"); + UUID uuid3 = UUID.randomUUID(); + tracker.markAsJoined(uuid1, "Alpha"); + tracker.markAsJoined(uuid2, "Beta"); + tracker.markAsJoined(uuid3, "Gamma"); + assertTrue(tracker.hasJoined(uuid1)); - assertFalse(tracker.hasJoined(uuid2)); + assertTrue(tracker.hasJoined(uuid2)); + assertTrue(tracker.hasJoined(uuid3)); } - // --- User cache importer tests + // ----------------------------------------------------------------------- + // exportAll + // ----------------------------------------------------------------------- @Test - void addUsersFromUserCache_Success() throws Exception { - // Arrange - Path testCache = tempDir.resolve("usercache.json"); - String json = """ - [ - {"name": "Notch", "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5"}, - {"name": "jeb_", "uuid": "853c80ef-3c37-49fd-aa49-938b674adae6"} - ] - """; - Files.writeString(testCache, json); - - UUID randomUUID = UUID.randomUUID(); - - tracker.markAsJoined(randomUUID, "Player 1"); - - boolean result = tracker.addUsersFromUserCache(testCache.toString()); - - assertTrue(result); - assertTrue(tracker.hasJoined(randomUUID)); - assertTrue(tracker.hasJoined(UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"))); - assertTrue(tracker.hasJoined(UUID.fromString("853c80ef-3c37-49fd-aa49-938b674adae6"))); + void exportAll_emptyDatabase_returnsEmptyMap() { + assertTrue(tracker.exportAll().isEmpty()); } @Test - void addUsersFromUserCache_FileNotFound() { - assertFalse(tracker.addUsersFromUserCache("/nonexistent/path")); + void exportAll_returnsAllRegisteredPlayers() { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + tracker.markAsJoined(uuid1, "Alpha"); + tracker.markAsJoined(uuid2, "Beta"); + + Map exported = tracker.exportAll(); + assertEquals(2, exported.size()); + assertEquals("Alpha", exported.get(uuid1)); + assertEquals("Beta", exported.get(uuid2)); } @Test - void addUsersFromUserCache_InvalidJson() throws Exception { - Path testCache = tempDir.resolve("usercache.json"); - Files.writeString(testCache, "INVALID_JSON"); + void exportAll_capturesPlayerNames() { + UUID uuid = UUID.randomUUID(); + tracker.markAsJoined(uuid, "Notch"); + + assertEquals("Notch", tracker.exportAll().get(uuid)); + } + + // ----------------------------------------------------------------------- + // UUID string representation round-trip + // ----------------------------------------------------------------------- - assertFalse(tracker.addUsersFromUserCache(testCache.toString())); + @Test + void uuidRoundTrip_dashFormattedUuid_roundTripsCorrectly() { + UUID uuid = UUID.fromString("069a79f4-44e9-4726-a5be-fca90e38aaf5"); + tracker.markAsJoined(uuid, "Notch"); + assertTrue(tracker.hasJoined(uuid)); + assertEquals("Notch", tracker.exportAll().get(uuid)); } + // ----------------------------------------------------------------------- + // Thread safety -- concurrent writes + // ----------------------------------------------------------------------- + @Test - void addUsersFromUserCache_EmptyArray() throws Exception { - Path testCache = tempDir.resolve("usercache.json"); - Files.writeString(testCache, "[]"); + void markAsJoined_concurrentCalls_noDataCorruption() throws Exception { + int threadCount = 20; + UUID[] uuids = new UUID[threadCount]; + for (int i = 0; i < threadCount; i++) uuids[i] = UUID.randomUUID(); + + Thread[] threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) { + final int idx = i; + threads[i] = new Thread(() -> tracker.markAsJoined(uuids[idx], "Player" + idx)); + } + for (Thread t : threads) t.start(); + for (Thread t : threads) t.join(); + + for (UUID uuid : uuids) { + assertTrue(tracker.hasJoined(uuid), + "All UUIDs should be tracked after concurrent inserts"); + } + } - boolean result = tracker.addUsersFromUserCache(testCache.toString()); + // ----------------------------------------------------------------------- + // Large batch + // ----------------------------------------------------------------------- - assertTrue(result); // Should still be successful but no users added + @Test + void markAsJoined_largeNumberOfPlayers_allTracked() { + int count = 200; + UUID[] uuids = new UUID[count]; + for (int i = 0; i < count; i++) { + uuids[i] = UUID.randomUUID(); + tracker.markAsJoined(uuids[i], "Player" + i); + } + assertEquals(count, tracker.exportAll().size()); + for (UUID uuid : uuids) { + assertTrue(tracker.hasJoined(uuid)); + } } } diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTrackerTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTrackerTest.java new file mode 100644 index 0000000..f769c1a --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTrackerTest.java @@ -0,0 +1,250 @@ +package xyz.earthcow.networkjoinmessages.common.storage; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class TextPlayerJoinTrackerTest { + + private Path tempFile; + private CoreLogger logger; + + @BeforeEach + void setup() throws Exception { + tempFile = Files.createTempFile("join-tracker-test", ".txt"); + // Remove file so the tracker creates its own header + Files.deleteIfExists(tempFile); + logger = Mockito.mock(CoreLogger.class); + } + + @AfterEach + void tearDown() throws Exception { + Files.deleteIfExists(tempFile); + } + + // ----------------------------------------------------------------------- + // Initialization -- file creation + // ----------------------------------------------------------------------- + + @Test + void init_fileDoesNotExist_createsFile() throws Exception { + new TextPlayerJoinTracker(logger, tempFile); + assertTrue(Files.exists(tempFile), "Tracker should create the file if absent"); + } + + @Test + void init_existingFile_doesNotThrow() throws Exception { + Files.writeString(tempFile, "# Comment\n"); + assertDoesNotThrow(() -> new TextPlayerJoinTracker(logger, tempFile)); + } + + // ----------------------------------------------------------------------- + // hasJoined -- fresh tracker + // ----------------------------------------------------------------------- + + @Test + void hasJoined_unknownUuid_returnsFalse() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + assertFalse(tracker.hasJoined(UUID.randomUUID())); + } + + @Test + void hasJoined_afterMarkAsJoined_returnsTrue() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + UUID uuid = UUID.randomUUID(); + tracker.markAsJoined(uuid, "Notch"); + assertTrue(tracker.hasJoined(uuid)); + } + + // ----------------------------------------------------------------------- + // markAsJoined -- idempotency + // ----------------------------------------------------------------------- + + @Test + void markAsJoined_calledTwice_noException() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + UUID uuid = UUID.randomUUID(); + assertDoesNotThrow(() -> { + tracker.markAsJoined(uuid, "Notch"); + tracker.markAsJoined(uuid, "Notch"); + }); + } + + @Test + void markAsJoined_calledTwice_appearsOnceInExport() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + UUID uuid = UUID.randomUUID(); + tracker.markAsJoined(uuid, "Notch"); + tracker.markAsJoined(uuid, "Notch"); // duplicate call + assertEquals(1, tracker.exportAll().size()); + } + + // ----------------------------------------------------------------------- + // markAsJoined -- multiple players + // ----------------------------------------------------------------------- + + @Test + void markAsJoined_multipleDistinctPlayers_allTracked() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + tracker.markAsJoined(uuid1, "Alpha"); + tracker.markAsJoined(uuid2, "Beta"); + + assertTrue(tracker.hasJoined(uuid1)); + assertTrue(tracker.hasJoined(uuid2)); + } + + // ----------------------------------------------------------------------- + // Persistence -- reload from file + // ----------------------------------------------------------------------- + + @Test + void markAsJoined_persistsToFile() throws Exception { + UUID uuid = UUID.randomUUID(); + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + tracker.markAsJoined(uuid, "Notch"); + + // Read a fresh tracker from the same file + TextPlayerJoinTracker reloaded = new TextPlayerJoinTracker(logger, tempFile); + assertTrue(reloaded.hasJoined(uuid), + "UUID should be persisted and visible after reload"); + } + + @Test + void markAsJoined_multipleEntries_allPersist() throws Exception { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + tracker.markAsJoined(uuid1, "Alpha"); + tracker.markAsJoined(uuid2, "Beta"); + + TextPlayerJoinTracker reloaded = new TextPlayerJoinTracker(logger, tempFile); + assertTrue(reloaded.hasJoined(uuid1)); + assertTrue(reloaded.hasJoined(uuid2)); + } + + // ----------------------------------------------------------------------- + // File format -- reading existing file content + // ----------------------------------------------------------------------- + + @Test + void init_parsesExistingUuidWithName() throws Exception { + UUID uuid = UUID.randomUUID(); + Files.writeString(tempFile, "# Header comment\n" + uuid + ":Notch\n"); + + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + assertTrue(tracker.hasJoined(uuid)); + } + + @Test + void init_parsesExistingUuidWithoutName() throws Exception { + UUID uuid = UUID.randomUUID(); + Files.writeString(tempFile, uuid + "\n"); + + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + assertTrue(tracker.hasJoined(uuid)); + } + + @Test + void init_skipsCommentLines() throws Exception { + Files.writeString(tempFile, "# This is a comment\n# Another comment\n"); + // Should not throw or produce any entries + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + assertTrue(tracker.exportAll().isEmpty()); + } + + @Test + void init_skipsBlankLines() throws Exception { + UUID uuid = UUID.randomUUID(); + Files.writeString(tempFile, "\n\n" + uuid + ":Player\n\n"); + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + assertEquals(1, tracker.exportAll().size()); + } + + @Test + void init_skipsInvalidUuidLines_logsInfo() throws Exception { + Files.writeString(tempFile, "not-a-valid-uuid:PlayerName\n"); + assertDoesNotThrow(() -> new TextPlayerJoinTracker(logger, tempFile)); + Mockito.verify(logger).info(Mockito.contains("Skipping")); + } + + @Test + void init_duplicateUuidInFile_onlyFirstEntryIsKept() throws Exception { + UUID uuid = UUID.randomUUID(); + Files.writeString(tempFile, uuid + ":FirstName\n" + uuid + ":SecondName\n"); + + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + // putIfAbsent semantics: first entry wins + assertEquals("FirstName", tracker.exportAll().get(uuid)); + } + + // ----------------------------------------------------------------------- + // exportAll + // ----------------------------------------------------------------------- + + @Test + void exportAll_emptyTracker_returnsEmptyMap() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + assertTrue(tracker.exportAll().isEmpty()); + } + + @Test + void exportAll_returnsAllRegisteredPlayers() throws Exception { + UUID uuid1 = UUID.randomUUID(); + UUID uuid2 = UUID.randomUUID(); + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + tracker.markAsJoined(uuid1, "Alpha"); + tracker.markAsJoined(uuid2, "Beta"); + + Map exported = tracker.exportAll(); + assertEquals(2, exported.size()); + assertEquals("Alpha", exported.get(uuid1)); + assertEquals("Beta", exported.get(uuid2)); + } + + @Test + void exportAll_returnsUnmodifiableView() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + tracker.markAsJoined(UUID.randomUUID(), "Player"); + + Map exported = tracker.exportAll(); + assertThrows(UnsupportedOperationException.class, + () -> exported.put(UUID.randomUUID(), "Injected"), + "exportAll should return an unmodifiable view"); + } + + // ----------------------------------------------------------------------- + // Thread safety -- concurrent access + // ----------------------------------------------------------------------- + + @Test + void markAsJoined_concurrentCalls_noDataCorruption() throws Exception { + TextPlayerJoinTracker tracker = new TextPlayerJoinTracker(logger, tempFile); + int threadCount = 20; + UUID[] uuids = new UUID[threadCount]; + for (int i = 0; i < threadCount; i++) uuids[i] = UUID.randomUUID(); + + Thread[] threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) { + final int idx = i; + threads[i] = new Thread(() -> tracker.markAsJoined(uuids[idx], "Player" + idx)); + } + for (Thread t : threads) t.start(); + for (Thread t : threads) t.join(); + + for (UUID uuid : uuids) { + assertTrue(tracker.hasJoined(uuid), + "All UUIDs should be tracked after concurrent inserts"); + } + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/util/LegacyColorTranslatorTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/util/LegacyColorTranslatorTest.java new file mode 100644 index 0000000..ba2f0d3 --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/util/LegacyColorTranslatorTest.java @@ -0,0 +1,196 @@ +package xyz.earthcow.networkjoinmessages.common.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link LegacyColorTranslator}. + * This class is pure logic with no dependencies, so no mocking is required. + */ +class LegacyColorTranslatorTest { + + // ----------------------------------------------------------------------- + // translate() -- basic passthrough + // ----------------------------------------------------------------------- + + @Test + void translate_plainTextIsUnchanged() { + assertEquals("Hello world", LegacyColorTranslator.translate("Hello world")); + } + + @Test + void translate_emptyStringIsUnchanged() { + assertEquals("", LegacyColorTranslator.translate("")); + } + + // ----------------------------------------------------------------------- + // translate() -- section-sign to ampersand normalisation + // ----------------------------------------------------------------------- + + @Test + void translate_sectionSignIsConvertedToAmpersand() { + // §c should resolve to the same MiniMessage tag as &c + String viaSectionSign = LegacyColorTranslator.translate("§cRed"); + String viaAmpersand = LegacyColorTranslator.translate("&cRed"); + assertEquals(viaAmpersand, viaSectionSign, + "Section-sign and ampersand codes must produce identical output"); + } + + // ----------------------------------------------------------------------- + // translate() -- named color codes -> MiniMessage hex tags + // ----------------------------------------------------------------------- + + @ParameterizedTest(name = "code {0} -> MiniMessage color tag") + @ValueSource(strings = {"&0","&1","&2","&3","&4","&5","&6","&7", + "&8","&9","&a","&b","&c","&d","&e","&f"}) + void translate_namedColorCodesProduceMiniMessageColorTags(String code) { + String result = LegacyColorTranslator.translate(code + "Text"); + assertTrue(result.startsWith("<#"), + "Named color code " + code + " should be converted to a hex MiniMessage tag, got: " + result); + assertTrue(result.endsWith(">Text"), + "Translated output should preserve trailing text, got: " + result); + } + + // ----------------------------------------------------------------------- + // translate() -- formatting codes + // ----------------------------------------------------------------------- + + @ParameterizedTest(name = "formatting code {0} -> correct MiniMessage tag") + @CsvSource({ + "&k, ", + "&l, ", + "&m, ", + "&n, ", + "&o, ", + "&r, " + }) + void translate_formattingCodesProduceCorrectTags(String code, String expectedTag) { + String result = LegacyColorTranslator.translate(code); + assertTrue(result.contains(expectedTag), + "Code " + code + " should produce " + expectedTag + " but got: " + result); + } + + // ----------------------------------------------------------------------- + // translate() -- inline hex shorthand (&#RRGGBB) + // ----------------------------------------------------------------------- + + @Test + void translate_inlineHexShorthandIsConverted() { + String result = LegacyColorTranslator.translate("&#FF0000Red"); + assertEquals("<#FF0000>Red", result); + } + + @Test + void translate_inlineHexShorthandLowercaseIsConverted() { + String result = LegacyColorTranslator.translate("&#ff0000Red"); + assertEquals("<#ff0000>Red", result); + } + + @Test + void translate_multipleInlineHexCodesInOneString() { + String result = LegacyColorTranslator.translate("&#FF0000Red �FF00Green"); + assertEquals("<#FF0000>Red <#00FF00>Green", result); + } + + @Test + void translate_inlineHexShorthandDoesNotMatchFiveDigits() { + // &#FFFFF should not be converted -- only exactly six hex digits + String input = "&#FFFFFShouldBeIgnored"; + String result = LegacyColorTranslator.translate(input); + // The six-digit match is "FFFFF" + next char, so check the regex does not greedily eat it + // The important thing: no <# tag should appear for a five-digit sequence + assertFalse(result.matches(".*<#[0-9a-fA-F]{5}>.*"), + "Five-digit hex should not be converted: " + result); + } + + // ----------------------------------------------------------------------- + // translate() -- Essentials hex (§x§r§r§g§g§b§b) + // ----------------------------------------------------------------------- + + @Test + void translate_essentialsHexIsConverted() { + // §x§f§b§6§3§f§5 => &#fb63f5 => <#fb63f5> + String result = LegacyColorTranslator.translate("§x§f§b§6§3§f§5Hello!"); + assertEquals("<#fb63f5>Hello!", result); + } + + @Test + void translate_essentialsHexUppercaseDigitsAreHandled() { + // §x§F§F§0§0§0§0 => &#FF0000 => <#FF0000> + String result = LegacyColorTranslator.translate("§x§F§F§0§0§0§0Hi"); + assertEquals("<#FF0000>Hi", result); + } + + @Test + void translate_essentialsHexMixedWithRegularText() { + String result = LegacyColorTranslator.translate("Before §x§f§f§0§0§0§0Red After"); + assertEquals("Before <#ff0000>Red After", result); + } + + // ----------------------------------------------------------------------- + // translate() -- literal newline placeholder + // ----------------------------------------------------------------------- + + @Test + void translate_literalBackslashNIsConvertedToNewlineTag() { + // The static map replaces the two-char sequence "\n" (backslash + n) + String result = LegacyColorTranslator.translate("Line1\\nLine2"); + assertTrue(result.contains(""), + "Literal '\\n' in config strings should become tag, got: " + result); + } + + // ----------------------------------------------------------------------- + // translate() -- compound strings + // ----------------------------------------------------------------------- + + @Test + void translate_compoundStringWithMultipleCodes() { + String result = LegacyColorTranslator.translate("&cRed &aGreen &bAqua &#FF0000Hex"); + // Must not contain any raw & codes after translation + assertFalse(result.contains("&c"), "Raw &c should have been translated"); + assertFalse(result.contains("&a"), "Raw &a should have been translated"); + assertFalse(result.contains("&b"), "Raw &b should have been translated"); + assertFalse(result.contains("&#FF0000"), "Raw &#FF0000 should have been translated"); + } + + @Test + void translate_allCodeTypesInSingleStringDoesNotThrow() { + assertDoesNotThrow(() -> + LegacyColorTranslator.translate( + "&cRed &aGreen &bAqua &#FF0000Hex §x§f§f§0§0§0§0EssHex &lBold &r" + ) + ); + } + + // ----------------------------------------------------------------------- + // ESSENTIALS_HEX_PATTERN (public field) + // ----------------------------------------------------------------------- + + @Test + void essentialsHexPattern_matchesValidEssentialsCode() { + Pattern p = LegacyColorTranslator.ESSENTIALS_HEX_PATTERN; + assertTrue(p.matcher("§x§f§b§6§3§f§5").find()); + } + + @Test + void essentialsHexPattern_doesNotMatchPlainText() { + assertFalse(LegacyColorTranslator.ESSENTIALS_HEX_PATTERN.matcher("Regular text").find()); + } + + @Test + void essentialsHexPattern_doesNotMatchIncompleteCode() { + // Only five digit segments instead of six + assertFalse(LegacyColorTranslator.ESSENTIALS_HEX_PATTERN.matcher("§x§f§b§6§3§f").find()); + } + + @Test + void essentialsHexPattern_matchesUppercaseDigits() { + assertTrue(LegacyColorTranslator.ESSENTIALS_HEX_PATTERN.matcher("§x§A§B§C§D§E§F").find()); + } +} diff --git a/src/test/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtilTest.java b/src/test/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtilTest.java new file mode 100644 index 0000000..c244986 --- /dev/null +++ b/src/test/java/xyz/earthcow/networkjoinmessages/common/util/PremiumVanishLevelUtilTest.java @@ -0,0 +1,143 @@ +package xyz.earthcow.networkjoinmessages.common.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlayer; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PremiumVanishLevelUtilTest { + + @Mock private CorePlayer player; + + // Helper: configure player to have exactly one numbered use level + private void grantUseLevel(int level) { + // Base permission + when(player.hasPermission("pv.use")).thenReturn(level >= 1); + // All numbered levels + for (int i = 1; i <= 100; i++) { + when(player.hasPermission("pv.use.level" + i)).thenReturn(i <= level); + } + } + + private void grantSeeLevel(int level) { + when(player.hasPermission("pv.see")).thenReturn(level >= 1); + for (int i = 1; i <= 100; i++) { + when(player.hasPermission("pv.see.level" + i)).thenReturn(i <= level); + } + } + + // ----------------------------------------------------------------------- + // updateVanishLevels -- use level + // ----------------------------------------------------------------------- + + @Test + void updateVanishLevels_noPermissions_useLevelIsZero() { + when(player.hasPermission(anyString())).thenReturn(false); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishUseLevel(0); + } + + @Test + void updateVanishLevels_baseUsePerm_useLevelIsOne() { + when(player.hasPermission(anyString())).thenReturn(false); + when(player.hasPermission("pv.use")).thenReturn(true); + // No numbered levels + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishUseLevel(1); + } + + @ParameterizedTest(name = "pv.use.level{0} granted => useLevel={0}") + @ValueSource(ints = {1, 2, 5, 10, 50, 99, 100}) + void updateVanishLevels_numberedUsePerm_useLevelMatchesHighest(int level) { + grantUseLevel(level); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishUseLevel(level); + } + + // ----------------------------------------------------------------------- + // updateVanishLevels -- see level + // ----------------------------------------------------------------------- + + @Test + void updateVanishLevels_noPermissions_seeLevelIsZero() { + when(player.hasPermission(anyString())).thenReturn(false); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishSeeLevel(0); + } + + @Test + void updateVanishLevels_baseSeePerm_seeLevelIsOne() { + when(player.hasPermission(anyString())).thenReturn(false); + when(player.hasPermission("pv.see")).thenReturn(true); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishSeeLevel(1); + } + + @ParameterizedTest(name = "pv.see.level{0} granted => seeLevel={0}") + @ValueSource(ints = {1, 3, 7, 25, 75, 100}) + void updateVanishLevels_numberedSeePerm_seeLevelMatchesHighest(int level) { + grantSeeLevel(level); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishSeeLevel(level); + } + + // ----------------------------------------------------------------------- + // updateVanishLevels -- both levels set in a single call + // ----------------------------------------------------------------------- + + @Test + void updateVanishLevels_bothLevelsSetAtOnce() { + grantUseLevel(3); + grantSeeLevel(7); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishUseLevel(3); + verify(player).setPremiumVanishSeeLevel(7); + } + + // ----------------------------------------------------------------------- + // updateVanishLevels -- highest level is truly the highest + // ----------------------------------------------------------------------- + + @Test + void updateVanishLevels_onlyHighestLevelGranted_returnsCorrectLevel() { + // Player has ONLY pv.use.level50 (no 1-49, no 51-100, no base pv.use) + when(player.hasPermission(anyString())).thenReturn(false); + when(player.hasPermission("pv.use.level50")).thenReturn(true); + + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishUseLevel(50); + } + + // ----------------------------------------------------------------------- + // updateVanishLevels -- no NPE when player has no permissions + // ----------------------------------------------------------------------- + + @Test + void updateVanishLevels_doesNotThrow() { + when(player.hasPermission(anyString())).thenReturn(false); + assertDoesNotThrow(() -> PremiumVanishLevelUtil.updateVanishLevels(player)); + } + + // ----------------------------------------------------------------------- + // Boundary -- level 100 is the maximum + // ----------------------------------------------------------------------- + + @Test + void updateVanishLevels_levelCappedAt100() { + grantUseLevel(100); + PremiumVanishLevelUtil.updateVanishLevels(player); + verify(player).setPremiumVanishUseLevel(100); + // No call should be made for level 101 + verify(player, never()).hasPermission("pv.use.level101"); + } +}