From 35cec99a3fdd74dab8162cd48bdbb048750ea1a5 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 5 Aug 2026 18:23:40 +0200 Subject: [PATCH 1/3] Extend NotifyLogin with configurable vanish transition messages --- docs/features/notifylogin.md | 200 +++++----- .../features/notifylogin/NotifyLogin.java | 69 +++- .../internal/ConnectionMessageSettings.java | 356 ++++++++++++++++++ .../internal/NotificationHandler.java | 320 +++++++++++++++- .../notifylogin/internal/NotifyLoginAPI.java | 27 ++ .../notifylogin/listener/PlayerListener.java | 15 +- .../features/notifylogin/meta/Meta.java | 4 +- .../features/vanish/internal/VanishAPI.java | 13 +- .../internal/VanishJoinStateTracker.java | 91 +++++ .../vanish/internal/VanishService.java | 121 +++++- .../vanish/listener/VisibilityListener.java | 10 +- .../features/vanish/meta/Meta.java | 2 +- .../localization/LocalizationHandler.java | 74 +++- .../listener/NametagListenerTest.java | 6 +- .../notifylogin/NotifyLoginDefaultsTest.java | 80 ++++ .../ConnectionMessageSettingsTest.java | 238 ++++++++++++ .../ConnectionMessageVanishSettingsTest.java | 46 +++ .../internal/NotificationHandlerTest.java | 109 ++++++ .../internal/VanishJoinStateTrackerTest.java | 100 +++++ .../localization/LocalizationHandlerTest.java | 105 +++++- 20 files changed, 1833 insertions(+), 153 deletions(-) create mode 100644 serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettings.java create mode 100644 serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotifyLoginAPI.java create mode 100644 serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTracker.java create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettingsTest.java create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageVanishSettingsTest.java create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandlerTest.java create mode 100644 serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTrackerTest.java diff --git a/docs/features/notifylogin.md b/docs/features/notifylogin.md index 17ac458e6..4be6ec1cd 100644 --- a/docs/features/notifylogin.md +++ b/docs/features/notifylogin.md @@ -2,146 +2,170 @@ > Paper · Feature name `NotifyLogin` · feature package `features.notifylogin` · disabled by default -NotifyLogin broadcasts one local join announcement when the joining player has the hard-coded Supreme+ permission and is not currently vanished according to the enabled ServerFeatures Vanish API. Every local online player receives the localized announcement; there is no recipient permission, delay, watched-player list, group configuration, world/server filter, database or Redis integration. +NotifyLogin is the authoritative local-server join and quit message feature. When enabled, it removes Paper's normal join and quit messages and optionally broadcasts a localized replacement selected for the connecting player. + +The feature supports defaults for every player, ordered permission-based overrides, exact player overrides and vanish-safe delivery. It does not use Vault groups, a database of its own, Redis or proxy-wide broadcasts. ## Commands and permissions -No command is registered. +No command is registered. Configuration changes are applied through the normal ServerFeatures reload lifecycle. -Trigger permission: +The default permission override uses: ```text serverfeatures.feature.notifylogin.supremeplus ``` -A joining player with this permission is eligible for announcement. It is not a recipient permission: all local online players receive the message. - -There is no bypass, staff-only audience, per-player toggle or admin command. +Additional permissions can be configured under `permission_overrides` without registering them in code. -## Complete configuration reference +## Default configuration File: `plugins/ServerFeatures/features/NotifyLogin/config.yml`. -| Key | Default | Meaning | -|---|---:|---| -| `enabled` | `false` | Registers the join listener. | +```yaml +enabled: false +announce_vanish_state_changes: true -No watched identities/groups, recipient permission, delay, server/world filters, message mode or vanish policy settings exist. Previous documentation suggesting them was inaccurate. +default: + join: notifylogin.default.join + quit: notifylogin.default.quit -## Join event contract +permission_overrides: + supremeplus: + priority: 100 + permission: serverfeatures.feature.notifylogin.supremeplus + join: notifylogin.group.supremeplus.join + quit: notifylogin.group.supremeplus.quit -```java -@EventHandler -public void onPlayerJoin(PlayerJoinEvent event) +player_overrides: {} ``` -Uses Bukkit defaults: +A message value is a localization key, not the rendered message itself. -- priority `NORMAL`; -- no cancellation setting (join is not normally cancellable). +- A missing `join` or `quit` value in a player or permission override inherits from the next matching layer. +- An explicitly empty value (`""`) suppresses that event and does not fall through. +- An empty or missing default value means no public message for that event. +- A present non-string message value is invalid, produces a startup warning and is suppressed rather than converted into a bogus key. +- An invalid priority produces a startup warning and uses `0`. +- `announce_vanish_state_changes` controls synthetic connection messages for explicit Vanish toggles and defaults to `true`. -Flow: +## Selection precedence -1. get joining player; -2. require `serverfeatures.feature.notifylogin.supremeplus`; -3. resolve VanishAPI through `FeatureServices.find`; -4. if API exists and reports joining UUID vanished, return; -5. otherwise iterate `Bukkit.getOnlinePlayers()`; -6. build `notifylogin.supremeplus` separately for each recipient audience; -7. inject joining Bukkit name as `{name}`; -8. send component. +Join and quit messages are resolved independently in this order: -The normal vanilla/Paper join message is not cancelled, replaced or modified. Recipients can therefore see both standard join presentation and NotifyLogin message. +1. exact UUID player override; +2. case-insensitive exact player-name override; +3. matching permission overrides ordered by descending `priority`; +4. the default message. -## Vanish integration +If multiple permission overrides use the same priority, their normalized identifiers are used as a deterministic tie-breaker and a startup warning is logged. Permission profile identifiers are case-insensitive; if the configuration contains duplicates that differ only by case, the last profile wins and a warning is logged. -Lookup is runtime/optional: +Example: -```java -FeatureServices.find(feature, VanishAPI.class) -``` +```yaml +permission_overrides: + staff: + priority: 1000 + permission: serverfeatures.staff + join: "" + quit: "" -- Vanish API available + vanished true: suppress announcement. -- API unavailable/feature disabled/failure represented as empty: treat player as not vanished and announce. + supremeplus: + priority: 100 + permission: serverfeatures.feature.notifylogin.supremeplus + join: notifylogin.group.supremeplus.join + quit: notifylogin.group.supremeplus.quit -There is no proxy/global vanish lookup or delayed recheck. The decision occurs synchronously during join. If vanish state is restored after `PlayerJoinEvent`, a staff member can be announced before Vanish marks them hidden. Feature enable/order and Vanish state bootstrap must therefore be tested. +player_overrides: + "b5cfd842-5455-38a2-9c0d-da059d1e39e5": + join: notifylogin.player.remymine.join + quit: "" -The implementation does not use `Player#canSee`, recipient-specific visibility or staff permission. If announced, even players who should not see the vanished staff identity receive the name. + ExamplePlayer: + join: notifylogin.player.example.join +``` -## Audience and localization +UUID overrides are preferred because they survive player-name changes. Name entries remain useful for simple administrative configuration. -Default key: +## Default messages -```text -notifylogin.supremeplus +```yaml +notifylogin: + default: + join: "[+] {name}" + quit: "[-] {name}" + + group: + supremeplus: + join: "[+] [Supreme+] %serverfeatures_nickname%" + quit: "[-] [Supreme+] %serverfeatures_nickname%" ``` -Variables: +The legacy `notifylogin.supremeplus` key is migrated to `notifylogin.group.supremeplus.join`. A customized old value is preserved when the new destination still contains its generated default; the obsolete key is then removed. Existing customized destination values are never overwritten, and language-specific legacy values are migrated in their own files. -- `{name}` — raw current Bukkit player name string. +## Placeholders and localization -The message is built for each recipient, enabling normal per-audience language/localization. The joining player is already present in `Bukkit.getOnlinePlayers()` during join handling and generally receives their own announcement. +Explicit subject placeholders: -There is no explicit PlaceholderAPI preprocessing beyond shared localization behaviour and no feature PAPI expansion. +- `{name}` — Bukkit account name; +- `{display_name}` — Adventure display-name component; +- `{uuid}` — player UUID; +- `{profile}` — selected source such as `default`, `group:supremeplus` or `player:`. -## Scope and ordering +Localization is selected separately for every recipient. PlaceholderAPI is evaluated against the joining or leaving player, not the recipient. This is required for values such as `%serverfeatures_nickname%` and prevents each recipient from seeing their own nickname in another player's announcement. -The broadcast is backend-local only. Players connected to other Paper servers/proxy do not receive it. +## Vanilla-message replacement -At default `NORMAL` priority: +At `HIGHEST` event priority NotifyLogin always sets: -- Vanish or permissions may still be initialized/changed by later listeners; -- another plugin can suppress normal join message without affecting this one; -- there is no delay to wait for resource-pack, client world load or chat readiness; -- notification is sent immediately during join event processing. +```java +event.joinMessage(null); +event.quitMessage(null); +``` -No quit notification exists. +This happens even when the selected custom value is empty. Enabling the feature therefore fully replaces Paper's standard local join and quit chat presentation. -## Threading and performance +The joining player receives their own join message. The quitting player is excluded from the quit broadcast. Recipients that cannot see the subject through Bukkit visibility are skipped. A rendering or delivery failure for one recipient is logged and does not prevent delivery to the remaining recipients. -Join event runs on main server thread. Cost is one permission/API lookup plus localized message build/send for every local online player (O(n)). No task or asynchronous work is created. +## Vanish integration -## Persistence, database and messaging +When ServerFeatures Vanish is enabled, NotifyLogin waits for Vanish's canonical initial-state readiness result before broadcasting a join message. Vanish performs the persisted-state read once, applies that result on the main thread, and only then completes the readiness stage consumed by NotifyLogin. The normal message is already suppressed while this is pending. -None: +- persisted or currently vanished: suppress the public join message; +- persisted visible and still visible: broadcast the resolved join message; +- lookup failure, scheduling failure or five-second timeout: fail closed, log the reason and suppress the message; +- player quits or reconnects before completion: discard the stale result. -- no DataProvider/database; -- no Redis/proxy messaging; -- no watched-user persistence; -- no API registration; -- no PlaceholderAPI expansion. +NotifyLogin retains the result needed for a rapid quit. Vanish clears its runtime state at `MONITOR`, after NotifyLogin has evaluated the quit event. A player who is still vanished therefore never receives a public quit message. A player who later leaves vanish and becomes visible may receive the normal configured quit message. -The Supreme+ trigger is permission-state driven at join time. +With `announce_vanish_state_changes: true`, explicit Vanish changes are presented as connection changes using the same player, permission and default message resolution: -## Lifecycle +- entering vanish broadcasts the configured quit message immediately before normal players lose Bukkit visibility of the staff member; +- leaving vanish restores Bukkit visibility first and then broadcasts the configured join message; +- a transition while the initial join is still pending is fenced, so entering vanish cannot produce a leave for a player who was never announced; +- persisted vanish restoration during a real login and vanish restoration during feature reload remain silent and never produce a misleading extra leave; +- setting the option to `false` disables only these synthetic messages; normal join/quit replacement and vanish privacy tracking remain active. -Initialization constructs `NotificationHandler` then registers one listener. Disable is empty; feature lifecycle unregisters listener. +When Vanish is unavailable at join time, the player is treated as visible. If a player was remembered as hidden and Vanish becomes unavailable before quit, NotifyLogin fails closed and suppresses that quit message rather than risking disclosure. -Enabling while players are already online does not announce them because there is no bootstrap loop. +## Scope and lifecycle -## Developer source map +Messages are backend-local. Moving between Paper backends can produce a quit message on the old backend and a join message on the new backend. Network-wide login/logout announcements require a separate proxy feature and are intentionally outside NotifyLogin. -- Defaults/message/lifecycle: `features/notifylogin/NotifyLogin.java` -- Permission/vanish/broadcast: `features/notifylogin/internal/NotificationHandler.java` -- Join event: `features/notifylogin/listener/PlayerListener.java` -- Metadata: `features/notifylogin/meta/Meta.java` +Configuration is parsed and validated once during feature initialization. Pending asynchronous joins are fenced with per-session generations and cleared on disable, preventing stale results from a previous connection or reload from being announced. ## Operational verification -1. Join with/without exact Supreme+ permission. -2. Verify every local player, including joining player, receives message. -3. Verify players on other backends receive nothing. -4. Test Vanish enabled with state already restored before join notification. -5. Test Vanish disabled/unavailable and delayed vanish restore for possible privacy leak. -6. Test multiple recipient languages and `{name}`. -7. Compare with normal join message and other join plugins. -8. Enable feature while users are online and confirm no retroactive messages. - -## Troubleshooting - -- **Configured watched groups/delay do nothing:** those settings do not exist. -- **Everyone receives announcement:** intentional; there is no recipient permission/filter. -- **Vanished staff was announced:** Vanish API was unavailable or state was not restored before default-priority join handling. -- **Duplicate join messages:** NotifyLogin does not suppress vanilla/other plugin join messages. -- **Other servers receive nothing:** no Redis/proxy broadcast exists. -- **Nickname not shown:** `{name}` uses raw Bukkit account name; Nickname is not consulted. +1. Enable NotifyLogin and confirm Paper's normal join and quit messages no longer appear. +2. Join without override permissions and verify the default `+` message. +3. Join with Supreme+ and verify the gradient prefix, spacing and subject nickname. +4. Configure two matching permissions and verify the higher priority wins. +5. Verify a missing event field falls through while `""` suppresses it. +6. Verify UUID overrides beat name and permission overrides. +7. Join and quit while persisted vanished; neither real connection message may be public. +8. Enter vanish while visible and verify one configured quit message appears before the player becomes hidden. +9. Leave vanish and verify one configured join message appears after the player becomes visible. +10. Set `announce_vanish_state_changes: false` and verify explicit vanish toggles no longer announce connection changes. +11. Test multiple recipient languages while the subject nickname remains correct. +12. Reload during a pending join and confirm no stale announcement is delivered. +13. Introduce an invalid message value, priority or vanish toggle setting and verify a clear warning plus safe fallback behavior. diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java index 6c97f4cc2..ac2999d4c 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java @@ -1,14 +1,34 @@ package nl.hauntedmc.serverfeatures.features.notifylogin; -import nl.hauntedmc.serverfeatures.features.FeatureContext; import nl.hauntedmc.serverfeatures.api.io.config.ConfigMap; import nl.hauntedmc.serverfeatures.api.io.localization.MessageMap; import nl.hauntedmc.serverfeatures.features.BukkitBaseFeature; +import nl.hauntedmc.serverfeatures.features.FeatureContext; +import nl.hauntedmc.serverfeatures.features.notifylogin.internal.ConnectionMessageSettings; import nl.hauntedmc.serverfeatures.features.notifylogin.internal.NotificationHandler; +import nl.hauntedmc.serverfeatures.features.notifylogin.internal.NotifyLoginAPI; import nl.hauntedmc.serverfeatures.features.notifylogin.listener.PlayerListener; import nl.hauntedmc.serverfeatures.features.notifylogin.meta.Meta; -public class NotifyLogin extends BukkitBaseFeature { +import java.util.LinkedHashMap; +import java.util.Map; + +public final class NotifyLogin extends BukkitBaseFeature { + + private static final String LEGACY_SUPREME_PLUS_MESSAGE = + "&3[Supreme&f+&3] {name} heeft de server gejoined!"; + private static final String DEFAULT_JOIN_MESSAGE = + "[+] {name}"; + private static final String DEFAULT_QUIT_MESSAGE = + "[-] {name}"; + private static final String SUPREME_PLUS_JOIN_MESSAGE = + "[+] " + + "" + + "[Supreme+] %serverfeatures_nickname%"; + private static final String SUPREME_PLUS_QUIT_MESSAGE = + "[-] " + + "" + + "[Supreme+] %serverfeatures_nickname%"; private NotificationHandler notificationHandler; @@ -18,32 +38,65 @@ public NotifyLogin(FeatureContext context) { @Override public ConfigMap getDefaultConfig() { + Map supremePlus = new LinkedHashMap<>(); + supremePlus.put("priority", 100); + supremePlus.put("permission", "serverfeatures.feature.notifylogin.supremeplus"); + supremePlus.put("join", "notifylogin.group.supremeplus.join"); + supremePlus.put("quit", "notifylogin.group.supremeplus.quit"); + + Map permissionOverrides = new LinkedHashMap<>(); + permissionOverrides.put("supremeplus", supremePlus); + ConfigMap defaults = new ConfigMap(); defaults.put("enabled", false); + defaults.put("announce_vanish_state_changes", true); + defaults.put("default", Map.of( + "join", "notifylogin.default.join", + "quit", "notifylogin.default.quit" + )); + defaults.put("permission_overrides", permissionOverrides); + defaults.put("player_overrides", Map.of()); return defaults; - } @Override public MessageMap getDefaultMessages() { MessageMap messages = new MessageMap(); - messages.add("notifylogin.supremeplus", "&3[Supreme&f+&3] {name} heeft de server gejoined!"); + messages.add("notifylogin.default.join", DEFAULT_JOIN_MESSAGE); + messages.add("notifylogin.default.quit", DEFAULT_QUIT_MESSAGE); + messages.add("notifylogin.group.supremeplus.join", SUPREME_PLUS_JOIN_MESSAGE); + messages.add("notifylogin.group.supremeplus.quit", SUPREME_PLUS_QUIT_MESSAGE); return messages; } - @Override public void initialize() { - this.notificationHandler = new NotificationHandler(this); + getLocalizationHandler().migrateMessageKey( + "notifylogin.supremeplus", + "notifylogin.group.supremeplus.join", + LEGACY_SUPREME_PLUS_MESSAGE, + SUPREME_PLUS_JOIN_MESSAGE + ); + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + getConfigHandler().node(), + message -> getLogger().warning(message) + ); + this.notificationHandler = new NotificationHandler(this, settings); + getLifecycleManager().getApiManager().registerService( + NotifyLoginAPI.class, + new NotifyLoginAPI(notificationHandler) + ); getLifecycleManager().getListenerManager().registerListener(new PlayerListener(this)); } @Override public void disable() { + if (notificationHandler != null) { + notificationHandler.close(); + } } public NotificationHandler getNotificationHandler() { return notificationHandler; } - -} \ No newline at end of file +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettings.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettings.java new file mode 100644 index 000000000..7a2f3b72e --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettings.java @@ -0,0 +1,356 @@ +package nl.hauntedmc.serverfeatures.features.notifylogin.internal; + +import nl.hauntedmc.serverfeatures.api.io.config.ConfigNode; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** Immutable, validated NotifyLogin message selection configuration. */ +public final class ConnectionMessageSettings { + private static final boolean DEFAULT_ANNOUNCE_VANISH_STATE_CHANGES = true; + + private final boolean announceVanishStateChanges; + private final MessageOverride defaultMessages; + private final List permissionOverrides; + private final Map playerOverridesByUuid; + private final Map playerOverridesByName; + + private ConnectionMessageSettings( + boolean announceVanishStateChanges, + MessageOverride defaultMessages, + List permissionOverrides, + Map playerOverridesByUuid, + Map playerOverridesByName + ) { + this.announceVanishStateChanges = announceVanishStateChanges; + this.defaultMessages = Objects.requireNonNull(defaultMessages, "defaultMessages"); + this.permissionOverrides = List.copyOf(permissionOverrides); + this.playerOverridesByUuid = Map.copyOf(playerOverridesByUuid); + this.playerOverridesByName = Map.copyOf(playerOverridesByName); + } + + public static ConnectionMessageSettings from(ConfigNode root, Consumer warningSink) { + Objects.requireNonNull(root, "root"); + Consumer warnings = warningSink == null ? ignored -> { } : warningSink; + boolean announceVanishStateChanges = parseBoolean( + root.get("announce_vanish_state_changes"), + DEFAULT_ANNOUNCE_VANISH_STATE_CHANGES, + warnings, + "announce_vanish_state_changes" + ); + MessageOverride defaults = parseMessages(root.get("default"), true, warnings, "default"); + List permissions = parsePermissionOverrides(root.get("permission_overrides"), warnings); + Map uuidOverrides = new LinkedHashMap<>(); + Map nameOverrides = new LinkedHashMap<>(); + parsePlayerOverrides(root.get("player_overrides"), uuidOverrides, nameOverrides, warnings); + return new ConnectionMessageSettings( + announceVanishStateChanges, + defaults, + permissions, + uuidOverrides, + nameOverrides + ); + } + + public boolean announceVanishStateChanges() { + return announceVanishStateChanges; + } + + public Resolution resolve( + UUID playerUuid, + String playerName, + Predicate permissionChecker, + EventType eventType + ) { + Objects.requireNonNull(playerUuid, "playerUuid"); + Objects.requireNonNull(permissionChecker, "permissionChecker"); + Objects.requireNonNull(eventType, "eventType"); + + Resolution resolution = resolve(playerOverridesByUuid.get(playerUuid), eventType, "player:" + playerUuid); + if (resolution != null) return resolution; + + String normalizedName = normalizeName(playerName); + if (!normalizedName.isEmpty()) { + resolution = resolve(playerOverridesByName.get(normalizedName), eventType, "player:" + normalizedName); + if (resolution != null) return resolution; + } + + for (PermissionOverride override : permissionOverrides) { + if (!permissionChecker.test(override.permission())) continue; + resolution = resolve(override.messages(), eventType, "group:" + override.id()); + if (resolution != null) return resolution; + } + + Resolution defaultResolution = resolve(defaultMessages, eventType, "default"); + return defaultResolution == null ? Resolution.suppressed("default") : defaultResolution; + } + + private static List parsePermissionOverrides(ConfigNode node, Consumer warnings) { + if (node.isNull()) { + return List.of(); + } + if (!(node.raw() instanceof Map)) { + warnings.accept("NotifyLogin setting 'permission_overrides' must be a section; all permission overrides were ignored."); + return List.of(); + } + + Map overridesById = new LinkedHashMap<>(); + for (Map.Entry entry : node.children().entrySet()) { + String configuredId = entry.getKey() == null ? "" : entry.getKey().trim(); + String id = normalizeIdentifier(configuredId); + ConfigNode profile = entry.getValue(); + if (id.isEmpty()) { + warnings.accept("NotifyLogin permission override has an empty identifier and was ignored."); + continue; + } + if (!(profile.raw() instanceof Map)) { + warnings.accept("NotifyLogin permission override '" + configuredId + + "' must be a section and was ignored."); + continue; + } + + Object permissionRaw = profile.get("permission").raw(); + if (!(permissionRaw instanceof String permissionValue) || permissionValue.trim().isEmpty()) { + warnings.accept("NotifyLogin permission override '" + configuredId + + "' has no valid permission and was ignored."); + continue; + } + String permission = permissionValue.trim(); + MessageOverride messages = parseMessages( + profile, + false, + warnings, + "permission_overrides." + configuredId + ); + if (!messages.hasDefinedValue()) { + warnings.accept("NotifyLogin permission override '" + configuredId + + "' defines neither join nor quit and was ignored."); + continue; + } + + int priority = parsePriority(profile.get("priority"), warnings, configuredId); + PermissionOverride replacement = new PermissionOverride(id, priority, permission, messages); + if (overridesById.put(id, replacement) != null) { + warnings.accept("NotifyLogin contains multiple case-insensitive permission overrides named '" + + configuredId + "'; the last value is used."); + } + } + + List overrides = new ArrayList<>(overridesById.values()); + overrides.sort(Comparator.comparingInt(PermissionOverride::priority).reversed() + .thenComparing(PermissionOverride::id, String.CASE_INSENSITIVE_ORDER)); + for (int index = 1; index < overrides.size(); index++) { + PermissionOverride previous = overrides.get(index - 1); + PermissionOverride current = overrides.get(index); + if (previous.priority() == current.priority()) { + warnings.accept("NotifyLogin permission overrides '" + previous.id() + "' and '" + + current.id() + "' use the same priority " + current.priority() + + "; identifier order is used as deterministic tie-breaker."); + } + } + return overrides; + } + + private static void parsePlayerOverrides( + ConfigNode node, + Map uuidOverrides, + Map nameOverrides, + Consumer warnings + ) { + if (node.isNull()) { + return; + } + if (!(node.raw() instanceof Map)) { + warnings.accept("NotifyLogin setting 'player_overrides' must be a section; all player overrides were ignored."); + return; + } + + for (Map.Entry entry : node.children().entrySet()) { + String identity = entry.getKey() == null ? "" : entry.getKey().trim(); + if (identity.isEmpty()) { + warnings.accept("NotifyLogin player override has an empty identity and was ignored."); + continue; + } + MessageOverride messages = parseMessages( + entry.getValue(), + false, + warnings, + "player_overrides." + identity + ); + if (!messages.hasDefinedValue()) { + warnings.accept("NotifyLogin player override '" + identity + + "' defines neither join nor quit and was ignored."); + continue; + } + try { + UUID uuid = UUID.fromString(identity); + if (uuidOverrides.put(uuid, messages) != null) { + warnings.accept("NotifyLogin contains multiple overrides for UUID " + uuid + + "; the last value is used."); + } + } catch (IllegalArgumentException ignored) { + String normalizedName = normalizeName(identity); + if (nameOverrides.put(normalizedName, messages) != null) { + warnings.accept("NotifyLogin contains multiple case-insensitive overrides for player '" + + identity + "'; the last value is used."); + } + } + } + } + + private static MessageOverride parseMessages( + ConfigNode node, + boolean defaultLayer, + Consumer warnings, + String context + ) { + if (node.isNull()) { + return fallbackMessages(defaultLayer); + } + if (!(node.raw() instanceof Map)) { + warnings.accept("NotifyLogin setting '" + context + + "' must be a section; its messages were ignored."); + return fallbackMessages(defaultLayer); + } + + Map fields = node.children(); + return new MessageOverride( + parseMessageValue(fields, "join", defaultLayer, warnings, context), + parseMessageValue(fields, "quit", defaultLayer, warnings, context) + ); + } + + private static MessageOverride fallbackMessages(boolean defaultLayer) { + MessageValue fallback = defaultLayer ? MessageValue.suppress() : MessageValue.inherit(); + return new MessageOverride(fallback, fallback); + } + + private static MessageValue parseMessageValue( + Map fields, + String key, + boolean defaultLayer, + Consumer warnings, + String context + ) { + if (!fields.containsKey(key)) { + return defaultLayer ? MessageValue.suppress() : MessageValue.inherit(); + } + + ConfigNode node = fields.get(key); + if (node == null || node.isNull()) { + return MessageValue.suppress(); + } + if (!(node.raw() instanceof String configuredValue)) { + warnings.accept("NotifyLogin setting '" + context + "." + key + + "' must be a localization key string or empty; this message was suppressed."); + return MessageValue.suppress(); + } + + String configured = configuredValue.trim(); + return configured.isEmpty() ? MessageValue.suppress() : MessageValue.message(configured); + } + + private static boolean parseBoolean( + ConfigNode node, + boolean fallback, + Consumer warnings, + String key + ) { + Object raw = node.raw(); + if (raw == null) { + return fallback; + } + if (raw instanceof Boolean configured) { + return configured; + } + warnings.accept("NotifyLogin setting '" + key + "' must be true or false; using " + fallback + "."); + return fallback; + } + + private static int parsePriority(ConfigNode node, Consumer warnings, String id) { + Object raw = node.raw(); + if (raw == null) { + return 0; + } + if (raw instanceof Number number) { + double value = number.doubleValue(); + if (Double.isFinite(value) + && value == Math.rint(value) + && value >= Integer.MIN_VALUE + && value <= Integer.MAX_VALUE) { + return (int) value; + } + } else if (raw instanceof String stringValue) { + try { + return Integer.parseInt(stringValue.trim()); + } catch (NumberFormatException ignored) { + // Warn below and use the deterministic fallback. + } + } + + warnings.accept("NotifyLogin permission override '" + id + + "' has invalid priority '" + raw + "'; using 0."); + return 0; + } + + private static Resolution resolve(MessageOverride override, EventType eventType, String source) { + if (override == null) return null; + MessageValue value = override.forEvent(eventType); + if (!value.defined()) return null; + return value.messageKey() == null + ? Resolution.suppressed(source) + : new Resolution(value.messageKey(), source); + } + + private static String normalizeIdentifier(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private static String normalizeName(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + public enum EventType { JOIN, QUIT } + + public record Resolution(String messageKey, String source) { + public Resolution { + Objects.requireNonNull(source, "source"); + } + + public static Resolution suppressed(String source) { + return new Resolution(null, source); + } + + public boolean suppressed() { + return messageKey == null; + } + } + + private record PermissionOverride(String id, int priority, String permission, MessageOverride messages) { } + + private record MessageOverride(MessageValue join, MessageValue quit) { + private MessageValue forEvent(EventType eventType) { + return eventType == EventType.JOIN ? join : quit; + } + + private boolean hasDefinedValue() { + return join.defined() || quit.defined(); + } + } + + private record MessageValue(boolean defined, String messageKey) { + private static MessageValue inherit() { return new MessageValue(false, null); } + private static MessageValue suppress() { return new MessageValue(true, null); } + private static MessageValue message(String key) { + return new MessageValue(true, Objects.requireNonNull(key, "key")); + } + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandler.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandler.java index aef53d568..f7fe1a9d6 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandler.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandler.java @@ -1,42 +1,322 @@ package nl.hauntedmc.serverfeatures.features.notifylogin.internal; +import nl.hauntedmc.serverfeatures.api.util.BukkitTime; import nl.hauntedmc.serverfeatures.features.notifylogin.NotifyLogin; import nl.hauntedmc.serverfeatures.features.vanish.internal.VanishAPI; import nl.hauntedmc.serverfeatures.framework.service.FeatureServices; import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Owns local join and quit messages while protecting persisted vanish state. + */ +public final class NotificationHandler { + private static final long VANISH_RESOLUTION_TIMEOUT_TICKS = 100L; -public class NotificationHandler { private final NotifyLogin feature; + private final ConnectionMessageSettings settings; + private final AtomicLong generationSequence = new AtomicLong(); + private final Map pendingJoins = new ConcurrentHashMap<>(); + private final Map knownVisibility = new ConcurrentHashMap<>(); - public NotificationHandler(NotifyLogin feature) { - this.feature = feature; + public NotificationHandler(NotifyLogin feature, ConnectionMessageSettings settings) { + this.feature = Objects.requireNonNull(feature, "feature"); + this.settings = Objects.requireNonNull(settings, "settings"); } - public void notify(@NotNull Player player) { - if (player.hasPermission("serverfeatures.feature.notifylogin.supremeplus")) { - if (isPlayerVanished(player)) { + public void handleJoin(PlayerJoinEvent event) { + event.joinMessage(null); + + Player player = event.getPlayer(); + UUID playerUuid = player.getUniqueId(); + long generation = generationSequence.incrementAndGet(); + pendingJoins.put(playerUuid, generation); + knownVisibility.put(playerUuid, VisibilityState.PENDING); + + Optional vanishApi; + try { + vanishApi = FeatureServices.find(feature, VanishAPI.class); + } catch (Throwable throwable) { + completeJoin(playerUuid, generation, null, VisibilityState.UNKNOWN, throwable); + return; + } + if (vanishApi.isEmpty()) { + completeJoin(playerUuid, generation, null, VisibilityState.VISIBLE, null); + return; + } + + VanishAPI api = vanishApi.get(); + CompletionStage initialState; + try { + initialState = api.resolveInitialVanishState(playerUuid); + } catch (Throwable throwable) { + completeJoin(playerUuid, generation, api, VisibilityState.UNKNOWN, throwable); + return; + } + if (initialState == null) { + completeJoin( + playerUuid, + generation, + api, + VisibilityState.UNKNOWN, + new IllegalStateException("Vanish returned no initial-state completion stage.") + ); + return; + } + + try { + initialState.whenComplete((initiallyVanished, throwable) -> { + try { + feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask(() -> { + VisibilityState state; + if (throwable != null || initiallyVanished == null) { + state = VisibilityState.UNKNOWN; + } else { + state = initiallyVanished ? VisibilityState.HIDDEN : VisibilityState.VISIBLE; + } + completeJoin(playerUuid, generation, api, state, throwable); + }); + } catch (Throwable schedulingFailure) { + completeJoin(playerUuid, generation, api, VisibilityState.UNKNOWN, schedulingFailure); + } + }); + } catch (Throwable callbackFailure) { + completeJoin(playerUuid, generation, api, VisibilityState.UNKNOWN, callbackFailure); + return; + } + + try { + feature.getLifecycleManager().getTaskManager().scheduleDelayedTask( + () -> completeJoin( + playerUuid, + generation, + api, + VisibilityState.UNKNOWN, + new TimeoutException("Initial vanish-state resolution exceeded " + + VANISH_RESOLUTION_TIMEOUT_TICKS + " ticks.") + ), + BukkitTime.ticks(VANISH_RESOLUTION_TIMEOUT_TICKS) + ); + } catch (Throwable schedulingFailure) { + completeJoin(playerUuid, generation, api, VisibilityState.UNKNOWN, schedulingFailure); + } + } + + public void handleQuit(PlayerQuitEvent event) { + event.quitMessage(null); + + Player player = event.getPlayer(); + UUID playerUuid = player.getUniqueId(); + boolean joinWasPending = pendingJoins.remove(playerUuid) != null; + VisibilityState visibility = knownVisibility.remove(playerUuid); + + Optional vanishApi; + try { + vanishApi = FeatureServices.find(feature, VanishAPI.class); + } catch (Throwable throwable) { + feature.getLogger().warning("Could not query vanish state while " + playerUuid + + " was leaving; suppressing the quit message: " + rootMessage(throwable)); + return; + } + + Boolean currentlyVanished = null; + if (vanishApi.isPresent()) { + try { + currentlyVanished = vanishApi.get().isVanished(playerUuid); + } catch (Throwable throwable) { + feature.getLogger().warning("Could not query current vanish state while " + playerUuid + + " was leaving; suppressing the quit message: " + rootMessage(throwable)); return; } - for (Player p : Bukkit.getOnlinePlayers()) { - p.sendMessage(feature.getLocalizationHandler().getMessage("notifylogin.supremeplus") - .forAudience(p) - .with("name", player.getName()) - .build()); - } } + + if (shouldSuppressQuit(joinWasPending, visibility, currentlyVanished)) { + return; + } + + broadcast(player, ConnectionMessageSettings.EventType.QUIT); } /** - * Checks if a player is vanished using the enabled Vanish feature API. + * Applies a synthetic leave or join announcement for an explicit Vanish state transition. * - * @param player The player to check. - * @return True if the player is vanished, false otherwise. + *

Entering vanish must call this before Bukkit visibility is removed. Leaving vanish must call + * this after Bukkit visibility is restored.

*/ - private boolean isPlayerVanished(Player player) { - return FeatureServices.find(feature, VanishAPI.class) - .map(api -> api.isVanished(player.getUniqueId())) - .orElse(false); + public void handleVanishStateChange(Player player, boolean vanished) { + Objects.requireNonNull(player, "player"); + + UUID playerUuid = player.getUniqueId(); + boolean joinWasPending = pendingJoins.remove(playerUuid) != null; + VisibilityState previousVisibility = knownVisibility.put( + playerUuid, + vanished ? VisibilityState.HIDDEN : VisibilityState.VISIBLE + ); + + if (!settings.announceVanishStateChanges() + || !shouldBroadcastVanishTransition(vanished, joinWasPending, previousVisibility)) { + return; + } + + broadcast( + player, + vanished ? ConnectionMessageSettings.EventType.QUIT : ConnectionMessageSettings.EventType.JOIN + ); + } + + public void close() { + pendingJoins.clear(); + knownVisibility.clear(); + } + + static boolean shouldSuppressQuit( + boolean joinWasPending, + VisibilityState rememberedVisibility, + Boolean currentlyVanished + ) { + if (joinWasPending + || rememberedVisibility == VisibilityState.PENDING + || rememberedVisibility == VisibilityState.UNKNOWN) { + return true; + } + if (Boolean.TRUE.equals(currentlyVanished)) { + return true; + } + return rememberedVisibility == VisibilityState.HIDDEN && currentlyVanished == null; + } + + static boolean shouldBroadcastVanishTransition( + boolean vanished, + boolean joinWasPending, + VisibilityState previousVisibility + ) { + if (!vanished) { + return previousVisibility != VisibilityState.VISIBLE; + } + return !joinWasPending + && previousVisibility != VisibilityState.PENDING + && previousVisibility != VisibilityState.UNKNOWN + && previousVisibility != VisibilityState.HIDDEN; + } + + private void completeJoin( + UUID playerUuid, + long generation, + VanishAPI vanishApi, + VisibilityState visibility, + Throwable failure + ) { + if (!pendingJoins.remove(playerUuid, generation)) { + return; + } + knownVisibility.put(playerUuid, visibility); + + if (failure != null) { + feature.getLogger().warning("Could not resolve initial vanish state for " + playerUuid + + "; suppressing the join message: " + rootMessage(failure)); + } + if (visibility != VisibilityState.VISIBLE) { + return; + } + + Player player = Bukkit.getPlayer(playerUuid); + if (player == null || !player.isOnline()) { + return; + } + if (vanishApi != null) { + try { + if (vanishApi.isVanished(playerUuid)) { + knownVisibility.put(playerUuid, VisibilityState.HIDDEN); + return; + } + } catch (Throwable throwable) { + knownVisibility.put(playerUuid, VisibilityState.UNKNOWN); + feature.getLogger().warning("Could not verify current vanish state for " + playerUuid + + "; suppressing the join message: " + rootMessage(throwable)); + return; + } + } + + broadcast(player, ConnectionMessageSettings.EventType.JOIN); + } + + private void broadcast(Player subject, ConnectionMessageSettings.EventType eventType) { + ConnectionMessageSettings.Resolution resolution; + try { + resolution = settings.resolve( + subject.getUniqueId(), + subject.getName(), + subject::hasPermission, + eventType + ); + } catch (Throwable throwable) { + feature.getLogger().warning("Could not resolve the NotifyLogin message for " + + subject.getUniqueId() + ": " + rootMessage(throwable)); + return; + } + if (resolution.suppressed()) { + return; + } + + for (Player recipient : Bukkit.getOnlinePlayers()) { + if (eventType == ConnectionMessageSettings.EventType.QUIT + && recipient.getUniqueId().equals(subject.getUniqueId())) { + continue; + } + if (!canSee(recipient, subject)) { + continue; + } + + try { + recipient.sendMessage(feature.getLocalizationHandler() + .getMessage(resolution.messageKey()) + .forAudience(recipient) + .withPlaceholderPlayer(subject) + .with("name", subject.getName()) + .with("display_name", subject.displayName()) + .with("uuid", subject.getUniqueId().toString()) + .with("profile", resolution.source()) + .build()); + } catch (Throwable throwable) { + feature.getLogger().warning("Could not deliver a NotifyLogin message about " + + subject.getUniqueId() + " to " + recipient.getUniqueId() + ": " + + rootMessage(throwable)); + } + } + } + + private static boolean canSee(Player recipient, Player subject) { + try { + return recipient.getUniqueId().equals(subject.getUniqueId()) || recipient.canSee(subject); + } catch (RuntimeException ignored) { + return false; + } + } + + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + } + String message = current.getMessage(); + return message == null || message.isBlank() ? current.getClass().getSimpleName() : message; + } + + enum VisibilityState { + PENDING, + VISIBLE, + HIDDEN, + UNKNOWN } } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotifyLoginAPI.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotifyLoginAPI.java new file mode 100644 index 000000000..28c16af5e --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotifyLoginAPI.java @@ -0,0 +1,27 @@ +package nl.hauntedmc.serverfeatures.features.notifylogin.internal; + +import org.bukkit.entity.Player; + +import java.util.Objects; + +/** + * Optional integration API for features that change a player's public connection visibility. + */ +public final class NotifyLoginAPI { + + private final NotificationHandler notificationHandler; + + public NotifyLoginAPI(NotificationHandler notificationHandler) { + this.notificationHandler = Objects.requireNonNull(notificationHandler, "notificationHandler"); + } + + /** + * Applies the configured synthetic leave or join message for an explicit vanish state change. + * + * @param player the player whose public visibility changed + * @param vanished {@code true} when the player entered vanish, {@code false} when they left vanish + */ + public void handleVanishStateChange(Player player, boolean vanished) { + notificationHandler.handleVanishStateChange(player, vanished); + } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/listener/PlayerListener.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/listener/PlayerListener.java index d2e8c4e2b..c29ce975a 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/listener/PlayerListener.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/listener/PlayerListener.java @@ -2,10 +2,15 @@ import nl.hauntedmc.serverfeatures.features.notifylogin.NotifyLogin; import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; -public class PlayerListener implements Listener { +/** + * Replaces Paper's local connection messages with NotifyLogin-owned messages. + */ +public final class PlayerListener implements Listener { private final NotifyLogin feature; @@ -13,9 +18,13 @@ public PlayerListener(NotifyLogin feature) { this.feature = feature; } - @EventHandler + @EventHandler(priority = EventPriority.HIGHEST) public void onPlayerJoin(PlayerJoinEvent event) { - feature.getNotificationHandler().notify(event.getPlayer()); + feature.getNotificationHandler().handleJoin(event); } + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerQuit(PlayerQuitEvent event) { + feature.getNotificationHandler().handleQuit(event); + } } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/meta/Meta.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/meta/Meta.java index ef2d0771d..d0167b740 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/meta/Meta.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/meta/Meta.java @@ -1,6 +1,5 @@ package nl.hauntedmc.serverfeatures.features.notifylogin.meta; - import nl.hauntedmc.serverfeatures.api.feature.meta.BaseMeta; public class Meta implements BaseMeta { @@ -12,7 +11,6 @@ public String getFeatureName() { @Override public String getFeatureVersion() { - return "1.0.0"; + return "2.0.0"; } - } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishAPI.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishAPI.java index f6e08ca73..429a2ccfa 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishAPI.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishAPI.java @@ -4,9 +4,10 @@ import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletionStage; /** - * Public API other features can use to query vanish-aware player stats. + * Public API other features can use to query vanish-aware player state. */ public class VanishAPI { @@ -33,4 +34,14 @@ public int getVanishedCount() { public boolean isVanished(UUID uuid) { return feature.getService().isVanished(uuid); } + + /** + * Completes after Vanish has resolved and applied the initial state for the active connection. + * + *

The completion is fenced to the current player session. Reconnects, disconnects and explicit + * state changes invalidate stale persistence results.

+ */ + public CompletionStage resolveInitialVanishState(UUID uuid) { + return feature.getService().awaitInitialVanishState(uuid); + } } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTracker.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTracker.java new file mode 100644 index 000000000..f741d4d6e --- /dev/null +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTracker.java @@ -0,0 +1,91 @@ +package nl.hauntedmc.serverfeatures.features.vanish.internal; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Fences asynchronous persisted-state restoration to one concrete player connection. + */ +final class VanishJoinStateTracker { + private final AtomicLong generationSequence = new AtomicLong(); + private final Map resolutions = new ConcurrentHashMap<>(); + + long begin(UUID playerUuid) { + Objects.requireNonNull(playerUuid, "playerUuid"); + long generation = generationSequence.incrementAndGet(); + Resolution replacement = new Resolution(generation, new CompletableFuture<>()); + Resolution previous = resolutions.put(playerUuid, replacement); + cancel(previous, "Superseded by a newer player connection."); + return generation; + } + + CompletionStage await(UUID playerUuid) { + Objects.requireNonNull(playerUuid, "playerUuid"); + Resolution resolution = resolutions.get(playerUuid); + if (resolution == null) { + return CompletableFuture.failedFuture(new IllegalStateException( + "No active Vanish join-state resolution exists for " + playerUuid + "." + )); + } + return resolution.future().minimalCompletionStage(); + } + + boolean isCurrent(UUID playerUuid, long generation) { + Resolution resolution = resolutions.get(playerUuid); + return resolution != null && resolution.generation() == generation; + } + + boolean complete(UUID playerUuid, long generation, boolean vanished) { + Resolution resolution = resolutions.get(playerUuid); + return resolution != null + && resolution.generation() == generation + && resolution.future().complete(vanished); + } + + boolean fail(UUID playerUuid, long generation, Throwable failure) { + Objects.requireNonNull(failure, "failure"); + Resolution resolution = resolutions.get(playerUuid); + return resolution != null + && resolution.generation() == generation + && resolution.future().completeExceptionally(failure); + } + + void override(UUID playerUuid, boolean vanished) { + Objects.requireNonNull(playerUuid, "playerUuid"); + long generation = generationSequence.incrementAndGet(); + Resolution replacement = new Resolution( + generation, + CompletableFuture.completedFuture(vanished) + ); + resolutions.compute(playerUuid, (ignored, previous) -> { + if (previous != null && !previous.future().isDone()) { + previous.future().complete(vanished); + } + return replacement; + }); + } + + void remove(UUID playerUuid) { + cancel(resolutions.remove(playerUuid), "Player disconnected before Vanish restoration completed."); + } + + void clear() { + resolutions.values().forEach(resolution -> + cancel(resolution, "Vanish was disabled before restoration completed.")); + resolutions.clear(); + } + + private static void cancel(Resolution resolution, String reason) { + if (resolution != null && !resolution.future().isDone()) { + resolution.future().completeExceptionally(new CancellationException(reason)); + } + } + + private record Resolution(long generation, CompletableFuture future) { } +} diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishService.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishService.java index 3d728a541..9bf4c4416 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishService.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishService.java @@ -3,8 +3,10 @@ import net.kyori.adventure.text.Component; import nl.hauntedmc.dataregistry.api.player.PlayerIdentity; import nl.hauntedmc.serverfeatures.api.util.BukkitTime; +import nl.hauntedmc.serverfeatures.features.notifylogin.internal.NotifyLoginAPI; import nl.hauntedmc.serverfeatures.features.vanish.Vanish; import nl.hauntedmc.serverfeatures.features.vanish.internal.messaging.EventBusHandler; +import nl.hauntedmc.serverfeatures.framework.service.FeatureServices; import org.bukkit.Bukkit; import org.bukkit.GameMode; import org.bukkit.entity.Player; @@ -16,6 +18,7 @@ import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; public class VanishService { @@ -26,6 +29,7 @@ public class VanishService { private final Vanish feature; private final Set vanished = ConcurrentHashMap.newKeySet(); private final Map playerIds = new ConcurrentHashMap<>(); + private final VanishJoinStateTracker joinStateTracker = new VanishJoinStateTracker(); public VanishService(Vanish feature) { this.feature = feature; @@ -73,6 +77,7 @@ public void restoreReloadSnapshot(Set vanishedPlayers, Map res if (player != null && player.isOnline()) { vanished.add(playerId); applyVanish(player); + joinStateTracker.override(playerId, true); } } for (Player viewer : Bukkit.getOnlinePlayers()) { @@ -80,6 +85,17 @@ public void restoreReloadSnapshot(Set vanishedPlayers, Map res } } + public long beginJoin(Player player) { + if (player == null || !player.isOnline()) { + return -1L; + } + return joinStateTracker.begin(player.getUniqueId()); + } + + public CompletionStage awaitInitialVanishState(UUID playerUuid) { + return joinStateTracker.await(playerUuid); + } + public void setVanished(Player target, boolean value) { if (target == null) { return; @@ -104,13 +120,20 @@ private void setVanishedInternal(Player target, boolean value, boolean persist) if (value) { vanished.add(playerUuid); + if (persist) { + notifyConnectionVisibilityChange(target, true); + } applyVanish(target); } else { vanished.remove(playerUuid); removeVanish(target); + if (persist) { + notifyConnectionVisibilityChange(target, false); + } } if (persist) { + joinStateTracker.override(playerUuid, value); Long playerId = playerIds.get(playerUuid); if (playerId == null) { playerId = feature.getRepository().findExistingPlayerId(playerUuid.toString()); @@ -139,38 +162,87 @@ public void handleJoin(Player player) { if (player == null || !player.isOnline()) { return; } + long generation = beginJoin(player); + if (generation < 0L) { + return; + } + Long playerId = feature.getRepository().findExistingPlayerId(player.getUniqueId().toString()); if (playerId == null) { - applyJoinState(player.getUniqueId(), false); + try { + if (!joinStateTracker.isCurrent(player.getUniqueId(), generation)) { + return; + } + applyJoinState(player.getUniqueId(), false); + joinStateTracker.complete(player.getUniqueId(), generation, false); + } catch (Throwable throwable) { + failJoinState(player.getUniqueId(), generation, throwable); + } return; } - handleJoin(player, new PlayerIdentity(playerId, player.getUniqueId(), player.getName())); + handleJoin(player, new PlayerIdentity(playerId, player.getUniqueId(), player.getName()), generation); } public void handleJoin(Player player, PlayerIdentity identity) { - if (player == null || !player.isOnline() || identity == null || identity.playerId() <= 0L) { + long generation = beginJoin(player); + if (generation >= 0L) { + handleJoin(player, identity, generation); + } + } + + public void handleJoin(Player player, PlayerIdentity identity, long generation) { + if (player == null) { return; } UUID playerUuid = player.getUniqueId(); - playerIds.put(playerUuid, identity.playerId()); - feature.getLifecycleManager().getTaskManager().supplyAsync( - () -> feature.getRepository().isPersistedVanished(identity.playerId()) - ).whenComplete((persistedVanished, throwable) -> { - if (throwable != null) { - feature.getLogger().warning("Kon vanish persistentie niet lezen: " + rootMessage(throwable)); - return; - } - feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask( - () -> applyJoinState(playerUuid, Boolean.TRUE.equals(persistedVanished)) + if (!player.isOnline() || identity == null || identity.playerId() <= 0L) { + failJoinState( + playerUuid, + generation, + new IllegalStateException("Valid player identity is required for Vanish join restoration.") ); - }); + return; + } + if (!joinStateTracker.isCurrent(playerUuid, generation)) { + return; + } + + playerIds.put(playerUuid, identity.playerId()); + try { + feature.getLifecycleManager().getTaskManager().supplyAsync( + () -> feature.getRepository().isPersistedVanished(identity.playerId()) + ).whenComplete((persistedVanished, throwable) -> { + if (throwable != null) { + failJoinState(playerUuid, generation, throwable); + return; + } + try { + feature.getLifecycleManager().getTaskManager().scheduleOneTimeTask(() -> { + if (!joinStateTracker.isCurrent(playerUuid, generation)) { + return; + } + try { + boolean restoredVanish = Boolean.TRUE.equals(persistedVanished); + applyJoinState(playerUuid, restoredVanish); + joinStateTracker.complete(playerUuid, generation, restoredVanish); + } catch (Throwable applyFailure) { + failJoinState(playerUuid, generation, applyFailure); + } + }); + } catch (Throwable schedulingFailure) { + failJoinState(playerUuid, generation, schedulingFailure); + } + }); + } catch (Throwable startFailure) { + failJoinState(playerUuid, generation, startFailure); + } } private void applyJoinState(UUID playerUuid, boolean persistedVanished) { Player player = Bukkit.getPlayer(playerUuid); if (player == null || !player.isOnline()) { - return; + throw new IllegalStateException("Player disconnected before Vanish join state could be applied."); } if (persistedVanished) { @@ -198,6 +270,13 @@ private void applyJoinState(UUID playerUuid, boolean persistedVanished) { applyToNewViewer(player); } + private void failJoinState(UUID playerUuid, long generation, Throwable throwable) { + if (joinStateTracker.fail(playerUuid, generation, throwable)) { + feature.getLogger().warning("Kon vanish persistentie niet lezen of toepassen voor " + + playerUuid + ": " + rootMessage(throwable)); + } + } + private void applyVanish(Player player) { for (Player viewer : Bukkit.getOnlinePlayers()) { updatePairVisibility(viewer, player); @@ -301,6 +380,7 @@ public void tickActionBars() { } public void cleanupOnDisable() { + joinStateTracker.clear(); for (UUID id : new HashSet<>(vanished)) { Player player = Bukkit.getPlayer(id); if (player != null && player.isOnline()) { @@ -311,6 +391,16 @@ public void cleanupOnDisable() { playerIds.clear(); } + private void notifyConnectionVisibilityChange(Player target, boolean vanishedState) { + try { + FeatureServices.find(feature, NotifyLoginAPI.class) + .ifPresent(api -> api.handleVanishStateChange(target, vanishedState)); + } catch (Throwable throwable) { + feature.getLogger().warning("Kon NotifyLogin niet bijwerken voor vanish wijziging van " + + target.getUniqueId() + ": " + rootMessage(throwable)); + } + } + private void publishVanishState(Player target, boolean value) { EventBusHandler bus = feature.getEventBusHandler(); if (bus == null || target == null) { @@ -326,6 +416,7 @@ private void publishVanishState(Player target, boolean value) { public void handleLeave(PlayerQuitEvent event) { UUID playerUuid = event.getPlayer().getUniqueId(); + joinStateTracker.remove(playerUuid); vanished.remove(playerUuid); playerIds.remove(playerUuid); } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/listener/VisibilityListener.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/listener/VisibilityListener.java index 4af6b5f4e..8802ba5c9 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/listener/VisibilityListener.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/listener/VisibilityListener.java @@ -19,18 +19,22 @@ public VisibilityListener(Vanish feature) { this.feature = feature; } - @EventHandler(priority = EventPriority.MONITOR) + @EventHandler(priority = EventPriority.LOWEST) public void onJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); + long generation = feature.getService().beginJoin(player); + if (generation < 0L) { + return; + } DataRegistryIdentityGate.runWhenReady( feature, player, - (readyPlayer, identity) -> feature.getService().handleJoin(readyPlayer, identity), + (readyPlayer, identity) -> feature.getService().handleJoin(readyPlayer, identity, generation), "vanish join" ); } - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onLeave(PlayerQuitEvent event) { feature.getService().handleLeave(event); } diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/meta/Meta.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/meta/Meta.java index e5c28edce..6a5eaf0a1 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/meta/Meta.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/vanish/meta/Meta.java @@ -13,7 +13,7 @@ public String getFeatureName() { @Override public String getFeatureVersion() { - return "1.0.0"; + return "1.1.0"; } @Override diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandler.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandler.java index 9e24f7230..1d4da3650 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandler.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandler.java @@ -127,6 +127,26 @@ public void migrateLegacyFeatureMessages(MessageMap messageMap) { staticPlayerMessages.clear(); } + /** + * Moves one feature-owned message key without overwriting an already customized destination. + */ + public void migrateMessageKey( + String oldKey, + String newKey, + String legacyDefault, + String newDefault + ) { + if (featureName == null || oldKey == null || oldKey.isBlank() + || newKey == null || newKey.isBlank() || oldKey.equals(newKey)) { + return; + } + migrateMessageKey(defaultMessagesView, oldKey, newKey, legacyDefault, newDefault, true); + for (ConfigView languageView : languageViews.values()) { + migrateMessageKey(languageView, oldKey, newKey, legacyDefault, newDefault, false); + } + staticPlayerMessages.clear(); + } + public MessageBuilder getMessage(String key) { return new MessageBuilder(key); } @@ -154,7 +174,7 @@ public Component build(String key) { Objects.requireNonNull(key, "key"); String raw = readPlayerMessage(key, language); if (raw.indexOf('%') >= 0) { - return render(raw, player, MessagePlaceholders.empty(), false, true); + return render(raw, player, player, MessagePlaceholders.empty(), false, true); } StaticMessageSlot slot = new StaticMessageSlot(language, key); @@ -162,7 +182,7 @@ public Component build(String key) { if (cached != null && cached.raw().equals(raw)) { return cached; } - Component component = render(raw, null, MessagePlaceholders.empty(), false, true); + Component component = render(raw, null, null, MessagePlaceholders.empty(), false, true); return new CachedStaticMessage(raw, component); }).component(); } @@ -171,6 +191,7 @@ public Component build(String key) { public final class MessageBuilder { private final String key; private Audience audience; + private Player placeholderPlayer; private MessagePlaceholders placeholders = MessagePlaceholders.empty(); private boolean autoLinkUrls; private boolean autoLinkUnderline = true; @@ -184,6 +205,14 @@ public MessageBuilder forAudience(Audience audience) { return this; } + /** + * Uses another player for PlaceholderAPI while retaining the audience for language selection. + */ + public MessageBuilder withPlaceholderPlayer(Player player) { + this.placeholderPlayer = player; + return this; + } + public MessageBuilder withPlaceholders(MessagePlaceholders placeholders) { if (placeholders != null) { this.placeholders = placeholders; @@ -220,7 +249,7 @@ public Component build() { String raw = audience instanceof Player player ? resolvePlayerMessage(key, player) : resolveNonPlayerMessage(key); - return render(raw, audience, placeholders, autoLinkUrls, autoLinkUnderline); + return render(raw, audience, placeholderPlayer, placeholders, autoLinkUrls, autoLinkUnderline); } } @@ -242,6 +271,7 @@ private String readPlayerMessage(String key, Language language) { private Component render( String raw, Audience audience, + Player placeholderPlayer, MessagePlaceholders placeholders, boolean autoLinkUrls, boolean autoLinkUnderline @@ -250,8 +280,12 @@ private Component render( .expect(TextFormatter.InputFormat.MIXED_INPUT) .preprocess(text -> { String replaced = text; - if (audience instanceof Player player) { - replaced = PlaceholderAPIHook.applyPlaceholders(replaced, player); + Player contextPlayer = placeholderPlayer; + if (contextPlayer == null && audience instanceof Player player) { + contextPlayer = player; + } + if (contextPlayer != null) { + replaced = PlaceholderAPIHook.applyPlaceholders(replaced, contextPlayer); } return MessagePlaceholders.applyPlaceholders(replaced, placeholders); }) @@ -292,6 +326,36 @@ private String missingMessage(String key) { return frameworkFallback == null ? "&cMessage not found: " + key : frameworkFallback.missingMessage(key); } + private void migrateMessageKey( + ConfigView view, + String oldKey, + String newKey, + String legacyDefault, + String newDefault, + boolean defaultView + ) { + String oldValue = view.get(oldKey, String.class); + if (oldValue == null) { + return; + } + + String currentNewValue = view.get(newKey, String.class); + boolean destinationUntouched = currentNewValue == null || Objects.equals(currentNewValue, newDefault); + boolean preserveOldValue = !defaultView || !Objects.equals(oldValue, legacyDefault); + view.batch(batch -> { + if (destinationUntouched) { + if (preserveOldValue) { + batch.put(newKey, oldValue); + } else if (currentNewValue == null && newDefault != null) { + batch.put(newKey, newDefault); + } + } + batch.remove(oldKey); + }); + logger.info("[ServerFeatures] [Localization] Migrated message key '" + oldKey + + "' to '" + newKey + "' for feature '" + featureName + "'."); + } + private void moveOwnedRootsFromLegacyStore(ConfigView source, ConfigView target, Set ownedRoots) { Map legacyValues = new LinkedHashMap<>(); for (String root : ownedRoots) { diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/nametags/listener/NametagListenerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/nametags/listener/NametagListenerTest.java index f10dd0de0..1ea6a21d0 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/nametags/listener/NametagListenerTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/nametags/listener/NametagListenerTest.java @@ -102,8 +102,10 @@ void crossWorldTeleportDefersToChangedWorldLifecycle() { NametagManager manager = mock(NametagManager.class); Player player = mock(Player.class); PlayerTeleportEvent event = mock(PlayerTeleportEvent.class); - Location from = new Location(mock(World.class), 0.0, 64.0, 0.0); - Location to = new Location(mock(World.class), 0.0, 64.0, 0.0); + World fromWorld = mock(World.class); + World toWorld = mock(World.class); + Location from = new Location(fromWorld, 0.0, 64.0, 0.0); + Location to = new Location(toWorld, 0.0, 64.0, 0.0); when(feature.getNametagManager()).thenReturn(manager); when(event.getPlayer()).thenReturn(player); when(event.getFrom()).thenReturn(from); diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java new file mode 100644 index 000000000..690c13ad9 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java @@ -0,0 +1,80 @@ +package nl.hauntedmc.serverfeatures.features.notifylogin; + +import nl.hauntedmc.serverfeatures.api.io.config.ConfigMap; +import nl.hauntedmc.serverfeatures.api.io.localization.MessageMap; +import nl.hauntedmc.serverfeatures.features.FeatureContext; +import nl.hauntedmc.serverfeatures.features.notifylogin.meta.Meta; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +class NotifyLoginDefaultsTest { + + @Test + @SuppressWarnings("unchecked") + void defaultConfigContainsOnlyTheSupremePlusPermissionOverride() { + FeatureContext context = mock(FeatureContext.class); + NotifyLogin feature = new NotifyLogin(context); + + ConfigMap config = feature.getDefaultConfig(); + Map root = config.toMap(); + assertEquals(false, root.get("enabled")); + assertEquals(true, root.get("announce_vanish_state_changes")); + assertEquals(Map.of( + "join", "notifylogin.default.join", + "quit", "notifylogin.default.quit" + ), root.get("default")); + + assertTrue(root.get("permission_overrides") instanceof Map); + Map permissionOverrides = (Map) root.get("permission_overrides"); + assertEquals(Set.of("supremeplus"), permissionOverrides.keySet()); + assertEquals(Map.of( + "priority", 100, + "permission", "serverfeatures.feature.notifylogin.supremeplus", + "join", "notifylogin.group.supremeplus.join", + "quit", "notifylogin.group.supremeplus.quit" + ), permissionOverrides.get("supremeplus")); + assertEquals(Map.of(), root.get("player_overrides")); + } + + @Test + @SuppressWarnings("unchecked") + void defaultMessagesMatchTheRequestedFormatting() { + FeatureContext context = mock(FeatureContext.class); + NotifyLogin feature = new NotifyLogin(context); + + MessageMap messageMap = feature.getDefaultMessages(); + Map messages = messageMap.getMessages(); + assertEquals(Set.of( + "notifylogin.default.join", + "notifylogin.default.quit", + "notifylogin.group.supremeplus.join", + "notifylogin.group.supremeplus.quit" + ), messages.keySet()); + assertEquals( + "[+] {name}", + messages.get("notifylogin.default.join") + ); + assertEquals( + "[-] {name}", + messages.get("notifylogin.default.quit") + ); + assertEquals( + "[+] " + + "" + + "[Supreme+] %serverfeatures_nickname%", + messages.get("notifylogin.group.supremeplus.join") + ); + assertEquals( + "[-] " + + "" + + "[Supreme+] %serverfeatures_nickname%", + messages.get("notifylogin.group.supremeplus.quit") + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettingsTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettingsTest.java new file mode 100644 index 000000000..e40d00d08 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageSettingsTest.java @@ -0,0 +1,238 @@ +package nl.hauntedmc.serverfeatures.features.notifylogin.internal; + +import nl.hauntedmc.serverfeatures.api.io.config.ConfigNode; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConnectionMessageSettingsTest { + + @Test + void resolvesPlayerPermissionAndDefaultLayersWithPerEventFallthrough() { + UUID playerUuid = UUID.randomUUID(); + Map root = new LinkedHashMap<>(); + root.put("default", Map.of( + "join", "default.join", + "quit", "default.quit" + )); + + Map permissionOverrides = new LinkedHashMap<>(); + permissionOverrides.put("high", Map.of( + "priority", 100, + "permission", "notify.high", + "quit", "" + )); + permissionOverrides.put("low", Map.of( + "priority", 10, + "permission", "notify.low", + "join", "low.join", + "quit", "low.quit" + )); + root.put("permission_overrides", permissionOverrides); + + Map playerOverrides = new LinkedHashMap<>(); + playerOverrides.put(playerUuid.toString(), Map.of("join", "uuid.join")); + playerOverrides.put("ExamplePlayer", Map.of("quit", "name.quit")); + root.put("player_overrides", playerOverrides); + + ConnectionMessageSettings settings = settings(root); + Set permissions = Set.of("notify.high", "notify.low"); + + ConnectionMessageSettings.Resolution uuidJoin = settings.resolve( + playerUuid, + "ExamplePlayer", + permissions::contains, + ConnectionMessageSettings.EventType.JOIN + ); + assertEquals("uuid.join", uuidJoin.messageKey()); + assertEquals("player:" + playerUuid, uuidJoin.source()); + + ConnectionMessageSettings.Resolution uuidQuit = settings.resolve( + playerUuid, + "ExamplePlayer", + permissions::contains, + ConnectionMessageSettings.EventType.QUIT + ); + assertEquals("name.quit", uuidQuit.messageKey()); + assertEquals("player:exampleplayer", uuidQuit.source()); + + ConnectionMessageSettings.Resolution permissionJoin = settings.resolve( + UUID.randomUUID(), + "OtherPlayer", + permissions::contains, + ConnectionMessageSettings.EventType.JOIN + ); + assertEquals("low.join", permissionJoin.messageKey()); + assertEquals("group:low", permissionJoin.source()); + + ConnectionMessageSettings.Resolution permissionQuit = settings.resolve( + UUID.randomUUID(), + "OtherPlayer", + permissions::contains, + ConnectionMessageSettings.EventType.QUIT + ); + assertTrue(permissionQuit.suppressed()); + assertEquals("group:high", permissionQuit.source()); + + ConnectionMessageSettings.Resolution defaultJoin = settings.resolve( + UUID.randomUUID(), + "DefaultPlayer", + ignored -> false, + ConnectionMessageSettings.EventType.JOIN + ); + assertEquals("default.join", defaultJoin.messageKey()); + assertEquals("default", defaultJoin.source()); + } + + @Test + void explicitPlayerSuppressionDoesNotFallThrough() { + Map root = new LinkedHashMap<>(); + root.put("default", Map.of("join", "default.join", "quit", "default.quit")); + root.put("permission_overrides", Map.of( + "vip", Map.of( + "priority", 10, + "permission", "notify.vip", + "join", "vip.join", + "quit", "vip.quit" + ) + )); + root.put("player_overrides", Map.of( + "QuietPlayer", Map.of("join", "") + )); + + ConnectionMessageSettings.Resolution resolution = settings(root).resolve( + UUID.randomUUID(), + "quietplayer", + permission -> permission.equals("notify.vip"), + ConnectionMessageSettings.EventType.JOIN + ); + + assertTrue(resolution.suppressed()); + assertEquals("player:quietplayer", resolution.source()); + } + + @Test + void explicitNullAndMalformedValuesFailClosed() { + Map root = new LinkedHashMap<>(); + root.put("default", Map.of("join", "default.join", "quit", "default.quit")); + + Map invalidProfile = new LinkedHashMap<>(); + invalidProfile.put("priority", "not-a-number"); + invalidProfile.put("permission", "notify.invalid"); + invalidProfile.put("join", Map.of("unexpected", "section")); + invalidProfile.put("quit", null); + root.put("permission_overrides", Map.of("invalid", invalidProfile)); + root.put("player_overrides", Map.of()); + + List warnings = new ArrayList<>(); + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + ConfigNode.ofRaw(root, "notifylogin"), + warnings::add + ); + + ConnectionMessageSettings.Resolution join = settings.resolve( + UUID.randomUUID(), + "Player", + permission -> permission.equals("notify.invalid"), + ConnectionMessageSettings.EventType.JOIN + ); + ConnectionMessageSettings.Resolution quit = settings.resolve( + UUID.randomUUID(), + "Player", + permission -> permission.equals("notify.invalid"), + ConnectionMessageSettings.EventType.QUIT + ); + + assertTrue(join.suppressed()); + assertTrue(quit.suppressed()); + assertTrue(warnings.stream().anyMatch(message -> message.contains("localization key string"))); + assertTrue(warnings.stream().anyMatch(message -> message.contains("invalid priority"))); + } + + @Test + void duplicateCaseInsensitivePermissionIdentifiersUseLastValue() { + Map root = new LinkedHashMap<>(); + root.put("default", Map.of("join", "default.join", "quit", "default.quit")); + + Map permissionOverrides = new LinkedHashMap<>(); + permissionOverrides.put("VIP", Map.of( + "priority", 10, + "permission", "notify.old", + "join", "old.join" + )); + permissionOverrides.put("vip", Map.of( + "priority", 20, + "permission", "notify.new", + "join", "new.join" + )); + root.put("permission_overrides", permissionOverrides); + root.put("player_overrides", Map.of()); + + List warnings = new ArrayList<>(); + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + ConfigNode.ofRaw(root, "notifylogin"), + warnings::add + ); + ConnectionMessageSettings.Resolution resolution = settings.resolve( + UUID.randomUUID(), + "Player", + permission -> permission.equals("notify.new"), + ConnectionMessageSettings.EventType.JOIN + ); + + assertEquals("new.join", resolution.messageKey()); + assertTrue(warnings.stream().anyMatch(message -> message.contains("case-insensitive permission overrides"))); + } + + @Test + void equalPrioritiesUseIdentifierOrderAndProduceAWarning() { + Map root = new LinkedHashMap<>(); + root.put("default", Map.of("join", "default.join", "quit", "default.quit")); + + Map permissionOverrides = new LinkedHashMap<>(); + permissionOverrides.put("zeta", Map.of( + "priority", 50, + "permission", "notify.zeta", + "join", "zeta.join" + )); + permissionOverrides.put("alpha", Map.of( + "priority", 50, + "permission", "notify.alpha", + "join", "alpha.join" + )); + root.put("permission_overrides", permissionOverrides); + root.put("player_overrides", Map.of()); + + List warnings = new ArrayList<>(); + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + ConfigNode.ofRaw(root, "notifylogin"), + warnings::add + ); + ConnectionMessageSettings.Resolution resolution = settings.resolve( + UUID.randomUUID(), + "Player", + ignored -> true, + ConnectionMessageSettings.EventType.JOIN + ); + + assertEquals("alpha.join", resolution.messageKey()); + assertFalse(warnings.isEmpty()); + assertTrue(warnings.getFirst().contains("same priority")); + } + + private static ConnectionMessageSettings settings(Map root) { + return ConnectionMessageSettings.from( + ConfigNode.ofRaw(root, "notifylogin"), + ignored -> { } + ); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageVanishSettingsTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageVanishSettingsTest.java new file mode 100644 index 000000000..c599b9631 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/ConnectionMessageVanishSettingsTest.java @@ -0,0 +1,46 @@ +package nl.hauntedmc.serverfeatures.features.notifylogin.internal; + +import nl.hauntedmc.serverfeatures.api.io.config.ConfigNode; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConnectionMessageVanishSettingsTest { + + @Test + void vanishStateAnnouncementsDefaultToEnabled() { + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + ConfigNode.ofRaw(Map.of(), "notifylogin"), + ignored -> { } + ); + + assertTrue(settings.announceVanishStateChanges()); + } + + @Test + void vanishStateAnnouncementsCanBeDisabled() { + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + ConfigNode.ofRaw(Map.of("announce_vanish_state_changes", false), "notifylogin"), + ignored -> { } + ); + + assertFalse(settings.announceVanishStateChanges()); + } + + @Test + void malformedVanishStateSettingUsesSafeDefaultAndWarns() { + List warnings = new ArrayList<>(); + ConnectionMessageSettings settings = ConnectionMessageSettings.from( + ConfigNode.ofRaw(Map.of("announce_vanish_state_changes", "yes"), "notifylogin"), + warnings::add + ); + + assertTrue(settings.announceVanishStateChanges()); + assertTrue(warnings.stream().anyMatch(message -> message.contains("announce_vanish_state_changes"))); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandlerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandlerTest.java new file mode 100644 index 000000000..f2cdb2892 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/internal/NotificationHandlerTest.java @@ -0,0 +1,109 @@ +package nl.hauntedmc.serverfeatures.features.notifylogin.internal; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NotificationHandlerTest { + + @Test + void quitVisibilityDecisionFailsClosedForUnresolvedAndHiddenStates() { + assertTrue(NotificationHandler.shouldSuppressQuit( + true, + NotificationHandler.VisibilityState.VISIBLE, + false + )); + assertTrue(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.PENDING, + false + )); + assertTrue(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.UNKNOWN, + false + )); + assertTrue(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.VISIBLE, + true + )); + assertTrue(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.HIDDEN, + null + )); + } + + @Test + void quitVisibilityDecisionAllowsKnownVisibleAndExplicitlyUnvanishedPlayers() { + assertFalse(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.VISIBLE, + false + )); + assertFalse(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.VISIBLE, + null + )); + assertFalse(NotificationHandler.shouldSuppressQuit( + false, + NotificationHandler.VisibilityState.HIDDEN, + false + )); + assertFalse(NotificationHandler.shouldSuppressQuit(false, null, null)); + } + + @Test + void enteringVanishAnnouncesOnlyAfterAVisibleJoinState() { + assertTrue(NotificationHandler.shouldBroadcastVanishTransition( + true, + false, + NotificationHandler.VisibilityState.VISIBLE + )); + assertTrue(NotificationHandler.shouldBroadcastVanishTransition(true, false, null)); + + assertFalse(NotificationHandler.shouldBroadcastVanishTransition( + true, + true, + NotificationHandler.VisibilityState.VISIBLE + )); + assertFalse(NotificationHandler.shouldBroadcastVanishTransition( + true, + false, + NotificationHandler.VisibilityState.PENDING + )); + assertFalse(NotificationHandler.shouldBroadcastVanishTransition( + true, + false, + NotificationHandler.VisibilityState.UNKNOWN + )); + assertFalse(NotificationHandler.shouldBroadcastVanishTransition( + true, + false, + NotificationHandler.VisibilityState.HIDDEN + )); + } + + @Test + void leavingVanishAnnouncesAJoinButRejectsDuplicateVisibleTransitions() { + assertTrue(NotificationHandler.shouldBroadcastVanishTransition( + false, + false, + NotificationHandler.VisibilityState.HIDDEN + )); + assertTrue(NotificationHandler.shouldBroadcastVanishTransition( + false, + true, + NotificationHandler.VisibilityState.PENDING + )); + assertTrue(NotificationHandler.shouldBroadcastVanishTransition(false, false, null)); + assertFalse(NotificationHandler.shouldBroadcastVanishTransition( + false, + false, + NotificationHandler.VisibilityState.VISIBLE + )); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTrackerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTrackerTest.java new file mode 100644 index 000000000..9d7d43433 --- /dev/null +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/vanish/internal/VanishJoinStateTrackerTest.java @@ -0,0 +1,100 @@ +package nl.hauntedmc.serverfeatures.features.vanish.internal; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VanishJoinStateTrackerTest { + + @Test + void completesTheCurrentConnectionAndRetainsItsResult() { + VanishJoinStateTracker tracker = new VanishJoinStateTracker(); + UUID playerUuid = UUID.randomUUID(); + long generation = tracker.begin(playerUuid); + CompletionStage result = tracker.await(playerUuid); + + assertTrue(tracker.isCurrent(playerUuid, generation)); + assertTrue(tracker.complete(playerUuid, generation, true)); + assertTrue(result.toCompletableFuture().join()); + assertTrue(tracker.await(playerUuid).toCompletableFuture().join()); + assertFalse(tracker.complete(playerUuid, generation, false)); + } + + @Test + void newerConnectionCancelsThePreviousResultAndRejectsStaleCompletion() { + VanishJoinStateTracker tracker = new VanishJoinStateTracker(); + UUID playerUuid = UUID.randomUUID(); + long oldGeneration = tracker.begin(playerUuid); + CompletionStage oldResult = tracker.await(playerUuid); + + long newGeneration = tracker.begin(playerUuid); + CompletionStage newResult = tracker.await(playerUuid); + + assertCancelled(oldResult); + assertFalse(tracker.isCurrent(playerUuid, oldGeneration)); + assertFalse(tracker.complete(playerUuid, oldGeneration, true)); + assertTrue(tracker.complete(playerUuid, newGeneration, false)); + assertFalse(newResult.toCompletableFuture().join()); + } + + @Test + void disconnectCancelsPendingResolutionAndRemovesIt() { + VanishJoinStateTracker tracker = new VanishJoinStateTracker(); + UUID playerUuid = UUID.randomUUID(); + tracker.begin(playerUuid); + CompletionStage result = tracker.await(playerUuid); + + tracker.remove(playerUuid); + + assertCancelled(result); + assertThrows(CompletionException.class, () -> tracker.await(playerUuid).toCompletableFuture().join()); + } + + @Test + void explicitStateOverrideCompletesPendingWaitersAndFencesTheDatabaseResult() { + VanishJoinStateTracker tracker = new VanishJoinStateTracker(); + UUID playerUuid = UUID.randomUUID(); + long databaseGeneration = tracker.begin(playerUuid); + CompletionStage pendingResult = tracker.await(playerUuid); + + tracker.override(playerUuid, true); + + assertTrue(pendingResult.toCompletableFuture().join()); + assertTrue(tracker.await(playerUuid).toCompletableFuture().join()); + assertFalse(tracker.isCurrent(playerUuid, databaseGeneration)); + assertFalse(tracker.complete(playerUuid, databaseGeneration, false)); + } + + @Test + void failuresAreDeliveredOnlyToTheCurrentConnection() { + VanishJoinStateTracker tracker = new VanishJoinStateTracker(); + UUID playerUuid = UUID.randomUUID(); + long generation = tracker.begin(playerUuid); + CompletionStage result = tracker.await(playerUuid); + IllegalStateException failure = new IllegalStateException("database unavailable"); + + assertTrue(tracker.fail(playerUuid, generation, failure)); + CompletionException thrown = assertThrows( + CompletionException.class, + () -> result.toCompletableFuture().join() + ); + assertEquals(failure, thrown.getCause()); + } + + private static void assertCancelled(CompletionStage stage) { + RuntimeException thrown = assertThrows( + RuntimeException.class, + () -> stage.toCompletableFuture().join() + ); + assertTrue(thrown instanceof CancellationException + || thrown.getCause() instanceof CancellationException); + } +} diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandlerTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandlerTest.java index 15a4e46f3..24382df72 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandlerTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/framework/localization/LocalizationHandlerTest.java @@ -1,11 +1,11 @@ package nl.hauntedmc.serverfeatures.framework.localization; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; import nl.hauntedmc.serverfeatures.ServerFeatures; import nl.hauntedmc.serverfeatures.api.io.config.ConfigService; import nl.hauntedmc.serverfeatures.api.io.config.ConfigView; import nl.hauntedmc.serverfeatures.api.io.localization.MessageMap; -import nl.hauntedmc.serverfeatures.api.util.text.format.ComponentFormatter; import nl.hauntedmc.serverfeatures.util.InterfaceProxy; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; @@ -83,6 +83,96 @@ void migrationDoesNotReshapeExistingFeatureMessageBranches() { assertTrue(legacy.node("demoAdded").isNull()); } + @Test + void messageKeyMigrationPreservesCustomizedLegacyValueOverGeneratedDestination() { + ServerFeatures plugin = mock(ServerFeatures.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("localization-test")); + ConfigService service = new ConfigService(plugin); + LocalizationHandler framework = new LocalizationHandler(plugin, service); + ConfigView target = service.view("features/Demo/messages.yml", false); + target.put("demo.old", "custom legacy value"); + target.put("demo.new", "new generated default"); + + LocalizationHandler feature = framework.openFeatureLocalization("Demo"); + feature.migrateMessageKey( + "demo.old", + "demo.new", + "old generated default", + "new generated default" + ); + + assertNull(target.get("demo.old")); + assertEquals("custom legacy value", target.get("demo.new", String.class)); + } + + @Test + void messageKeyMigrationInstallsNewDefaultWhenDestinationIsMissing() { + ServerFeatures plugin = mock(ServerFeatures.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("localization-test")); + ConfigService service = new ConfigService(plugin); + LocalizationHandler framework = new LocalizationHandler(plugin, service); + ConfigView target = service.view("features/Demo/messages.yml", false); + target.put("demo.old", "old generated default"); + + LocalizationHandler feature = framework.openFeatureLocalization("Demo"); + feature.migrateMessageKey( + "demo.old", + "demo.new", + "old generated default", + "new generated default" + ); + + assertNull(target.get("demo.old")); + assertEquals("new generated default", target.get("demo.new", String.class)); + } + + @Test + void messageKeyMigrationDoesNotOverwriteCustomizedDestination() { + ServerFeatures plugin = mock(ServerFeatures.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("localization-test")); + ConfigService service = new ConfigService(plugin); + LocalizationHandler framework = new LocalizationHandler(plugin, service); + ConfigView target = service.view("features/Demo/messages.yml", false); + target.put("demo.old", "custom legacy value"); + target.put("demo.new", "custom destination value"); + + LocalizationHandler feature = framework.openFeatureLocalization("Demo"); + feature.migrateMessageKey( + "demo.old", + "demo.new", + "old generated default", + "new generated default" + ); + + assertNull(target.get("demo.old")); + assertEquals("custom destination value", target.get("demo.new", String.class)); + } + + @Test + void messageKeyMigrationPreservesLanguageSpecificLegacyValue() { + ServerFeatures plugin = mock(ServerFeatures.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("localization-test")); + ConfigService service = new ConfigService(plugin); + LocalizationHandler framework = new LocalizationHandler(plugin, service); + ConfigView language = service.view("features/Demo/messages_EN.yml", false); + language.put("demo.old", "translated legacy value"); + + LocalizationHandler feature = framework.openFeatureLocalization("Demo"); + feature.migrateMessageKey( + "demo.old", + "demo.new", + "old generated default", + "new generated default" + ); + + assertNull(language.get("demo.old")); + assertEquals("translated legacy value", language.get("demo.new", String.class)); + } + @Test void playerMessageContextReusesStaticComponentsAndObservesTemplateChanges() { ServerFeatures plugin = mock(ServerFeatures.class); @@ -114,8 +204,15 @@ private static Player player() { } private static String plain(Component component) { - return ComponentFormatter.serialize(component) - .format(ComponentFormatter.Serializer.Format.PLAIN) - .build(); + StringBuilder output = new StringBuilder(); + appendText(component, output); + return output.toString(); + } + + private static void appendText(Component component, StringBuilder output) { + if (component instanceof TextComponent text) { + output.append(text.content()); + } + component.children().forEach(child -> appendText(child, output)); } } From acc97fb60f95c159e44aefe745820ebfa57e9dd4 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 5 Aug 2026 18:34:01 +0200 Subject: [PATCH 2/3] Remove trailing space --- .../serverfeatures/features/notifylogin/NotifyLogin.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java index ac2999d4c..3e37a00ad 100644 --- a/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java +++ b/serverfeatures-platform-paper/src/main/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLogin.java @@ -22,11 +22,11 @@ public final class NotifyLogin extends BukkitBaseFeature { private static final String DEFAULT_QUIT_MESSAGE = "[-] {name}"; private static final String SUPREME_PLUS_JOIN_MESSAGE = - "[+] " + "[+] " + "" + "[Supreme+] %serverfeatures_nickname%"; private static final String SUPREME_PLUS_QUIT_MESSAGE = - "[-] " + "[-] " + "" + "[Supreme+] %serverfeatures_nickname%"; From 932ad75b6b8950743ae5d6902d22b610211fc530 Mon Sep 17 00:00:00 2001 From: Remy Duijsens Date: Wed, 5 Aug 2026 20:55:07 +0200 Subject: [PATCH 3/3] Fix failing test --- .../features/notifylogin/NotifyLoginDefaultsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java index 690c13ad9..4f7a71c1c 100644 --- a/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java +++ b/serverfeatures-platform-paper/src/test/java/nl/hauntedmc/serverfeatures/features/notifylogin/NotifyLoginDefaultsTest.java @@ -65,13 +65,13 @@ void defaultMessagesMatchTheRequestedFormatting() { messages.get("notifylogin.default.quit") ); assertEquals( - "[+] " + "[+] " + "" + "[Supreme+] %serverfeatures_nickname%", messages.get("notifylogin.group.supremeplus.join") ); assertEquals( - "[-] " + "[-] " + "" + "[Supreme+] %serverfeatures_nickname%", messages.get("notifylogin.group.supremeplus.quit")