Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 112 additions & 88 deletions docs/features/notifylogin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<color:#aab2c9>[<color:#ffd79c>+<color:#aab2c9>] {name}"
quit: "<color:#aab2c9>[<color:#ffd79c>-<color:#aab2c9>] {name}"

group:
supremeplus:
join: "<color:#aab2c9>[<color:#ffd79c>+<color:#aab2c9>] <gradient:#3B8585:#3B8585:#2B9D9D:#2B9D9D:#43B1B1:#43B1B1:#44D6D6:#EAEAEA:#44D6D6>[Supreme+]</gradient> <color:#aab2c9>%serverfeatures_nickname%"
quit: "<color:#aab2c9>[<color:#ffd79c>-<color:#aab2c9>] <gradient:#3B8585:#3B8585:#2B9D9D:#2B9D9D:#43B1B1:#43B1B1:#44D6D6:#EAEAEA:#44D6D6>[Supreme+]</gradient> <color:#aab2c9>%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:<identity>`.

## 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.
Original file line number Diff line number Diff line change
@@ -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<Meta> {
import java.util.LinkedHashMap;
import java.util.Map;

public final class NotifyLogin extends BukkitBaseFeature<Meta> {

private static final String LEGACY_SUPREME_PLUS_MESSAGE =
"&3[Supreme&f+&3] {name} heeft de server gejoined!";
private static final String DEFAULT_JOIN_MESSAGE =
"<color:#aab2c9>[<color:#ffd79c>+<color:#aab2c9>] {name}";
private static final String DEFAULT_QUIT_MESSAGE =
"<color:#aab2c9>[<color:#ffd79c>-<color:#aab2c9>] {name}";
private static final String SUPREME_PLUS_JOIN_MESSAGE =
"<color:#aab2c9>[<color:#ffd79c>+<color:#aab2c9>] "
+ "<gradient:#3B8585:#3B8585:#2B9D9D:#2B9D9D:#43B1B1:#43B1B1:#44D6D6:#EAEAEA:#44D6D6>"
+ "[Supreme+]</gradient> <color:#aab2c9>%serverfeatures_nickname%";
private static final String SUPREME_PLUS_QUIT_MESSAGE =
"<color:#aab2c9>[<color:#ffd79c>-<color:#aab2c9>] "
+ "<gradient:#3B8585:#3B8585:#2B9D9D:#2B9D9D:#43B1B1:#43B1B1:#44D6D6:#EAEAEA:#44D6D6>"
+ "[Supreme+]</gradient> <color:#aab2c9>%serverfeatures_nickname%";

private NotificationHandler notificationHandler;

Expand All @@ -18,32 +38,65 @@ public NotifyLogin(FeatureContext<Meta> context) {

@Override
public ConfigMap getDefaultConfig() {
Map<String, Object> 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<String, Object> 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;
}

}
}
Loading