From dfd7d64534292699a68f0d46aff198a3e84a4c30 Mon Sep 17 00:00:00 2001 From: ItzStanleex Date: Sat, 18 Jul 2026 00:58:50 +0200 Subject: [PATCH] Sync vanish state across the network for whispers Vanish state was only ever read live from the local platform (Bukkit metadata on Paper), so it was correct only for players on the same server as the viewer. For players on another networked server the resolved CarbonPlayer reported vanished() == false, so vanished players still appeared in /msg (and aliases) tab completion and could be messaged cross-server, ignoring carbon.whisper.vanished. Vanish state is now carried in the player-presence packets and stored in NetworkUsers, so awareness checks honor it network-wide: - LocalPlayerChangePacket / LocalPlayersPacket now carry a vanished flag (new PlayerInfo record as the presence value type). - NetworkUsers stores per-player vanish state and exposes awareOf(viewer, other) / vanished(other), which fall back to the synced state for remote players. Whisper suggestions and the whisper send guard use it. - Join/quit and the init handshake seed the state; VanishSync polls the abstract CarbonPlayer#vanished() and broadcasts changes, so runtime toggles propagate for any supported vanish plugin (PremiumVanish, SuperVanish, VanishNoPacket, ...) on any platform, with no compile- or runtime dependency on a specific plugin. --- .../carbon/common/CarbonChatInternal.java | 9 ++ .../command/commands/WhisperCommand.java | 2 +- .../common/messaging/MessagingManager.java | 5 +- .../carbon/common/messaging/VanishSync.java | 92 +++++++++++++++++++ .../packets/LocalPlayerChangePacket.java | 12 ++- .../messaging/packets/LocalPlayersPacket.java | 21 ++++- .../messaging/packets/PacketFactory.java | 8 +- .../common/messaging/packets/PlayerInfo.java | 33 +++++++ .../carbon/common/users/NetworkUsers.java | 53 +++++++++-- .../listeners/FabricJoinQuitListener.java | 3 +- .../listeners/PaperPlayerJoinListener.java | 16 +++- 11 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 common/src/main/java/net/draycia/carbon/common/messaging/VanishSync.java create mode 100644 common/src/main/java/net/draycia/carbon/common/messaging/packets/PlayerInfo.java diff --git a/common/src/main/java/net/draycia/carbon/common/CarbonChatInternal.java b/common/src/main/java/net/draycia/carbon/common/CarbonChatInternal.java index 223511bcf..4b99d9539 100644 --- a/common/src/main/java/net/draycia/carbon/common/CarbonChatInternal.java +++ b/common/src/main/java/net/draycia/carbon/common/CarbonChatInternal.java @@ -39,6 +39,7 @@ import net.draycia.carbon.common.listeners.Listener; import net.draycia.carbon.common.messages.CarbonMessages; import net.draycia.carbon.common.messaging.MessagingManager; +import net.draycia.carbon.common.messaging.VanishSync; import net.draycia.carbon.common.messaging.packets.PacketFactory; import net.draycia.carbon.common.users.PlayerUtils; import net.draycia.carbon.common.users.ProfileCache; @@ -123,6 +124,14 @@ protected void init() { TimeUnit.SECONDS ); + final VanishSync vanishSync = this.injector.getInstance(VanishSync.class); + this.periodicTasks.scheduleAtFixedRate( + vanishSync::pollAndBroadcast, + VanishSync.POLL_INTERVAL_SECONDS, + VanishSync.POLL_INTERVAL_SECONDS, + TimeUnit.SECONDS + ); + this.initIntegrations(); // Load channels diff --git a/common/src/main/java/net/draycia/carbon/common/command/commands/WhisperCommand.java b/common/src/main/java/net/draycia/carbon/common/command/commands/WhisperCommand.java index 3e0a704cb..f04c602a7 100644 --- a/common/src/main/java/net/draycia/carbon/common/command/commands/WhisperCommand.java +++ b/common/src/main/java/net/draycia/carbon/common/command/commands/WhisperCommand.java @@ -181,7 +181,7 @@ public void whisper( } final String recipientUsername = recipient.username(); - if (!this.network.online(recipient) || !sender.awareOf(recipient) && !sender.hasPermission("carbon.whisper.vanished")) { + if (!this.network.online(recipient) || !this.network.awareOf(sender, recipient) && !sender.hasPermission("carbon.whisper.vanished")) { final var exception = new CarbonPlayerParser.ParseException( recipientInputString == null ? recipientUsername : recipientInputString, this.messages diff --git a/common/src/main/java/net/draycia/carbon/common/messaging/MessagingManager.java b/common/src/main/java/net/draycia/carbon/common/messaging/MessagingManager.java index 6d12128a6..bb011e654 100644 --- a/common/src/main/java/net/draycia/carbon/common/messaging/MessagingManager.java +++ b/common/src/main/java/net/draycia/carbon/common/messaging/MessagingManager.java @@ -48,6 +48,7 @@ import net.draycia.carbon.common.messaging.packets.PacketFactory; import net.draycia.carbon.common.messaging.packets.PartyChangePacket; import net.draycia.carbon.common.messaging.packets.PartyInvitePacket; +import net.draycia.carbon.common.messaging.packets.PlayerInfo; import net.draycia.carbon.common.messaging.packets.SaveCompletedPacket; import net.draycia.carbon.common.messaging.packets.WhisperPacket; import net.draycia.carbon.common.users.NetworkUsers; @@ -289,9 +290,9 @@ private CarbonServerHandler( protected void handleInitialization(final @NonNull InitializationPacket packet) { super.handleInitialization(packet); final List players = this.server.players(); - final Map map = new HashMap<>(); + final Map map = new HashMap<>(); for (final CarbonPlayer player : players) { - map.put(player.uuid(), player.username()); + map.put(player.uuid(), new PlayerInfo(player.username(), player.vanished())); } this.packetService.queuePacket(this.packetFactory.localPlayersPacket(map)); } diff --git a/common/src/main/java/net/draycia/carbon/common/messaging/VanishSync.java b/common/src/main/java/net/draycia/carbon/common/messaging/VanishSync.java new file mode 100644 index 000000000..742ac1236 --- /dev/null +++ b/common/src/main/java/net/draycia/carbon/common/messaging/VanishSync.java @@ -0,0 +1,92 @@ +/* + * CarbonChat + * + * Copyright (c) 2024 Josua Parks (Vicarious) + * Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.draycia.carbon.common.messaging; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.draycia.carbon.api.CarbonServer; +import net.draycia.carbon.api.users.CarbonPlayer; +import net.draycia.carbon.common.messaging.packets.PacketFactory; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.framework.qual.DefaultQualifier; + +/** + * Detects local vanish state changes and broadcasts them to the rest of the network. + * + *

{@link CarbonPlayer#vanished()} only reflects live state for players on the querying server, so + * remote servers rely on the state synced through the player-presence packets. Join/quit seed the + * initial state; this task keeps it up to date when players are hidden or shown at runtime.

+ * + *

Rather than hooking a specific vanish plugin's events (there is no common Bukkit event for vanish + * changes), this polls the same {@link CarbonPlayer#vanished()} value CarbonChat already reads, so it + * works with any supported vanish plugin (PremiumVanish, SuperVanish, VanishNoPacket, ...) and on any + * platform.

+ */ +@DefaultQualifier(NonNull.class) +@Singleton +public final class VanishSync { + + public static final long POLL_INTERVAL_SECONDS = 3; + + private final CarbonServer server; + private final Provider messaging; + private final PacketFactory packetFactory; + private final Map lastKnown = new ConcurrentHashMap<>(); + + @Inject + private VanishSync( + final CarbonServer server, + final Provider messaging, + final PacketFactory packetFactory + ) { + this.server = server; + this.messaging = messaging; + this.packetFactory = packetFactory; + } + + public void pollAndBroadcast() { + final Set online = new HashSet<>(); + + for (final CarbonPlayer player : this.server.players()) { + final UUID uuid = player.uuid(); + online.add(uuid); + + final boolean vanished = player.vanished(); + final @Nullable Boolean previous = this.lastKnown.put(uuid, vanished); + + // First observation: the join packet already seeds the state, so only correct it when the + // player turns out to be vanished (in case vanish was applied after the join packet was sent). + final boolean changed = previous == null ? vanished : previous != vanished; + if (changed) { + this.messaging.get().queuePacket(() -> this.packetFactory.addLocalPlayerPacket(uuid, player.username(), vanished)); + } + } + + this.lastKnown.keySet().retainAll(online); + } + +} diff --git a/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayerChangePacket.java b/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayerChangePacket.java index d135aa6ca..59d80b496 100644 --- a/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayerChangePacket.java +++ b/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayerChangePacket.java @@ -35,13 +35,15 @@ public final class LocalPlayerChangePacket extends CarbonPacket { private @MonotonicNonNull UUID playerId; private @MonotonicNonNull String playerName; private @MonotonicNonNull ChangeType changeType; + private boolean vanished; @AssistedInject public LocalPlayerChangePacket( final @ServerId UUID serverId, final @Assisted UUID playerId, final @Assisted @Nullable String playerName, - final @Assisted ChangeType changeType + final @Assisted ChangeType changeType, + final @Assisted boolean vanished ) { super(serverId); if (changeType == ChangeType.ADD && playerName == null) { @@ -50,6 +52,7 @@ public LocalPlayerChangePacket( this.playerId = playerId; this.playerName = playerName; this.changeType = changeType; + this.vanished = vanished; } @AssistedInject @@ -58,6 +61,7 @@ public LocalPlayerChangePacket(final @ServerId UUID serverId, final @Assisted UU this.playerId = playerId; this.playerName = null; this.changeType = ChangeType.REMOVE; + this.vanished = false; } public LocalPlayerChangePacket(final UUID sender, final ByteBuf data) { @@ -77,6 +81,10 @@ public ChangeType changeType() { return this.changeType; } + public boolean vanished() { + return this.vanished; + } + @Override public void read(final ByteBuf buffer) { this.playerId = this.readUUID(buffer); @@ -84,6 +92,7 @@ public void read(final ByteBuf buffer) { this.changeType = ChangeType.valueOf(type); if (this.changeType == ChangeType.ADD) { this.playerName = this.readString(buffer); + this.vanished = buffer.readBoolean(); } } @@ -93,6 +102,7 @@ public void write(final ByteBuf buffer) { this.writeString(this.changeType.name(), buffer); if (this.changeType == ChangeType.ADD) { this.writeString(this.playerName, buffer); + buffer.writeBoolean(this.vanished); } } diff --git a/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayersPacket.java b/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayersPacket.java index b8c030187..205cfde99 100644 --- a/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayersPacket.java +++ b/common/src/main/java/net/draycia/carbon/common/messaging/packets/LocalPlayersPacket.java @@ -32,12 +32,12 @@ @DefaultQualifier(NonNull.class) public final class LocalPlayersPacket extends CarbonPacket { - private @MonotonicNonNull Map players; + private @MonotonicNonNull Map players; @AssistedInject public LocalPlayersPacket( final @ServerId UUID serverId, - final @Assisted Map players + final @Assisted Map players ) { super(serverId); this.players = players; @@ -48,18 +48,29 @@ public LocalPlayersPacket(final UUID sender, final ByteBuf data) { this.read(data); } - public Map players() { + public Map players() { return this.players; } @Override public void read(final ByteBuf buffer) { - this.players = this.readMap(buffer, this::readUUID, this::readString); + this.players = this.readMap(buffer, this::readUUID, this::readPlayerInfo); } @Override public void write(final ByteBuf buffer) { - this.writeMap(this.players, this::writeUUID, this::writeString, buffer); + this.writeMap(this.players, this::writeUUID, this::writePlayerInfo, buffer); + } + + private PlayerInfo readPlayerInfo(final ByteBuf buffer) { + final String name = this.readString(buffer); + final boolean vanished = buffer.readBoolean(); + return new PlayerInfo(name, vanished); + } + + private void writePlayerInfo(final PlayerInfo info, final ByteBuf buffer) { + this.writeString(info.name(), buffer); + buffer.writeBoolean(info.vanished()); } } diff --git a/common/src/main/java/net/draycia/carbon/common/messaging/packets/PacketFactory.java b/common/src/main/java/net/draycia/carbon/common/messaging/packets/PacketFactory.java index 8e7304884..a1bb40e66 100644 --- a/common/src/main/java/net/draycia/carbon/common/messaging/packets/PacketFactory.java +++ b/common/src/main/java/net/draycia/carbon/common/messaging/packets/PacketFactory.java @@ -33,16 +33,16 @@ public interface PacketFactory { SaveCompletedPacket saveCompletedPacket(UUID playerId); - LocalPlayersPacket localPlayersPacket(Map players); + LocalPlayersPacket localPlayersPacket(Map players); default LocalPlayersPacket clearLocalPlayersPacket() { return this.localPlayersPacket(Map.of()); } - LocalPlayerChangePacket localPlayerChangePacket(UUID player, @Nullable String name, LocalPlayerChangePacket.ChangeType type); + LocalPlayerChangePacket localPlayerChangePacket(UUID player, @Nullable String name, LocalPlayerChangePacket.ChangeType type, boolean vanished); - default LocalPlayerChangePacket addLocalPlayerPacket(final UUID id, final String name) { - return this.localPlayerChangePacket(id, name, LocalPlayerChangePacket.ChangeType.ADD); + default LocalPlayerChangePacket addLocalPlayerPacket(final UUID id, final String name, final boolean vanished) { + return this.localPlayerChangePacket(id, name, LocalPlayerChangePacket.ChangeType.ADD, vanished); } LocalPlayerChangePacket removeLocalPlayerPacket(final UUID id); diff --git a/common/src/main/java/net/draycia/carbon/common/messaging/packets/PlayerInfo.java b/common/src/main/java/net/draycia/carbon/common/messaging/packets/PlayerInfo.java new file mode 100644 index 000000000..c3591b4c5 --- /dev/null +++ b/common/src/main/java/net/draycia/carbon/common/messaging/packets/PlayerInfo.java @@ -0,0 +1,33 @@ +/* + * CarbonChat + * + * Copyright (c) 2024 Josua Parks (Vicarious) + * Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package net.draycia.carbon.common.messaging.packets; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.framework.qual.DefaultQualifier; + +/** + * A network player's shared, eventually consistent state. + * + * @param name the player's username + * @param vanished whether the player is vanished on the server they're connected to + */ +@DefaultQualifier(NonNull.class) +public record PlayerInfo(String name, boolean vanished) { +} diff --git a/common/src/main/java/net/draycia/carbon/common/users/NetworkUsers.java b/common/src/main/java/net/draycia/carbon/common/users/NetworkUsers.java index 7a58b6d0a..2e7bc4120 100644 --- a/common/src/main/java/net/draycia/carbon/common/users/NetworkUsers.java +++ b/common/src/main/java/net/draycia/carbon/common/users/NetworkUsers.java @@ -38,6 +38,7 @@ import net.draycia.carbon.common.command.argument.PlayerSuggestions; import net.draycia.carbon.common.messaging.packets.LocalPlayerChangePacket; import net.draycia.carbon.common.messaging.packets.LocalPlayersPacket; +import net.draycia.carbon.common.messaging.packets.PlayerInfo; import net.draycia.carbon.common.util.Exceptions; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; @@ -56,7 +57,7 @@ public final class NetworkUsers implements PlayerSuggestions { private final CarbonServer server; - private final Map> map = new ConcurrentHashMap<>(); + private final Map> map = new ConcurrentHashMap<>(); private final UserManager userManager; private final ProfileCache profileCache; @@ -72,11 +73,11 @@ private NetworkUsers( } public void handlePacket(final LocalPlayerChangePacket packet) { - final Map serverMap = this.map.computeIfAbsent(packet.getSender(), $ -> new ConcurrentHashMap<>()); + final Map serverMap = this.map.computeIfAbsent(packet.getSender(), $ -> new ConcurrentHashMap<>()); switch (packet.changeType()) { case ADD -> { - serverMap.put(packet.playerId(), packet.playerName()); + serverMap.put(packet.playerId(), new PlayerInfo(packet.playerName(), packet.vanished())); this.profileCache.cache(packet.playerId(), packet.playerName()); } case REMOVE -> serverMap.remove(packet.playerId()); @@ -89,11 +90,11 @@ public void handlePacket(final LocalPlayersPacket packet) { if (packet.players().isEmpty()) { this.map.remove(packet.getSender()); } else { - final Map serverMap = this.map.computeIfAbsent(packet.getSender(), $ -> new ConcurrentHashMap<>()); + final Map serverMap = this.map.computeIfAbsent(packet.getSender(), $ -> new ConcurrentHashMap<>()); serverMap.clear(); serverMap.putAll(packet.players()); - packet.players().forEach(this.profileCache::cache); + packet.players().forEach((uuid, info) -> this.profileCache.cache(uuid, info.name())); } } @@ -106,7 +107,7 @@ public CompletableFuture> suggestionsFuture(final CommandCo if (!(commander instanceof PlayerCommander player)) { return CompletableFuture.completedFuture( - Stream.concat(local.stream().map(CarbonPlayer::username), this.map.values().stream().flatMap(m -> m.values().stream())) + Stream.concat(local.stream().map(CarbonPlayer::username), this.map.values().stream().flatMap(m -> m.values().stream()).map(PlayerInfo::name)) .distinct() .map(Suggestion::suggestion) .toList() @@ -132,7 +133,7 @@ public CompletableFuture> suggestionsFuture(final CommandCo return CompletableFuture.completedFuture( Stream.concat(local.stream(), remote) - .filter(carbonPlayer::awareOf) + .filter(other -> this.awareOf(carbonPlayer, other)) .map(CarbonPlayer::username) .distinct() .map(Suggestion::suggestion) @@ -140,6 +141,44 @@ public CompletableFuture> suggestionsFuture(final CommandCo ); } + /** + * Whether {@code other} is vanished, either locally or on the remote server they're connected to. + * + *

{@link CarbonPlayer#vanished()} only reflects live state for players on the querying server; + * for players elsewhere on the network we consult the eventually consistent state synced here.

+ * + * @param other the (potentially remote) player + * @return whether the player is vanished anywhere on the network + */ + public boolean vanished(final CarbonPlayer other) { + if (other.vanished()) { + return true; + } + return this.vanished(other.uuid()); + } + + private boolean vanished(final UUID uuid) { + return this.map.values().stream() + .map(server -> server.get(uuid)) + .filter(Objects::nonNull) + .anyMatch(PlayerInfo::vanished); + } + + /** + * Network-aware variant of {@link CarbonPlayer#awareOf(CarbonPlayer)} that also honors the vanish + * state of players connected to other servers on the network. + * + * @param viewer the player attempting to see/message {@code other} + * @param other the (potentially remote, potentially vanished) player + * @return whether {@code viewer} should be aware of {@code other} + */ + public boolean awareOf(final CarbonPlayer viewer, final CarbonPlayer other) { + if (this.vanished(other)) { + return viewer.hasPermission("carbon.whisper.vanished"); + } + return true; + } + public boolean online(final CarbonPlayer player) { if (player.online()) { return true; diff --git a/fabric/src/main/java/net/draycia/carbon/fabric/listeners/FabricJoinQuitListener.java b/fabric/src/main/java/net/draycia/carbon/fabric/listeners/FabricJoinQuitListener.java index 4ed19a8bf..c69cf36d3 100644 --- a/fabric/src/main/java/net/draycia/carbon/fabric/listeners/FabricJoinQuitListener.java +++ b/fabric/src/main/java/net/draycia/carbon/fabric/listeners/FabricJoinQuitListener.java @@ -69,7 +69,8 @@ public FabricJoinQuitListener( @Override public void onPlayReady(final ServerGamePacketListenerImpl handler, final PacketSender sender, final MinecraftServer server) { this.profileCache.cache(handler.getPlayer().getUUID(), handler.getPlayer().getGameProfile().name()); - this.messaging.get().queuePacket(() -> this.packetFactory.addLocalPlayerPacket(handler.getPlayer().getUUID(), handler.getPlayer().getGameProfile().name())); + // Fabric has no vanish integration; vanish state is always false here. + this.messaging.get().queuePacket(() -> this.packetFactory.addLocalPlayerPacket(handler.getPlayer().getUUID(), handler.getPlayer().getGameProfile().name(), false)); final @Nullable List suggestions = this.configManager.primaryConfig().customChatSuggestions(); diff --git a/paper/src/main/java/net/draycia/carbon/paper/listeners/PaperPlayerJoinListener.java b/paper/src/main/java/net/draycia/carbon/paper/listeners/PaperPlayerJoinListener.java index 6f66cd512..2f3709842 100644 --- a/paper/src/main/java/net/draycia/carbon/paper/listeners/PaperPlayerJoinListener.java +++ b/paper/src/main/java/net/draycia/carbon/paper/listeners/PaperPlayerJoinListener.java @@ -28,12 +28,14 @@ import net.draycia.carbon.common.users.ProfileCache; import net.draycia.carbon.common.users.UserManagerInternal; import org.apache.logging.log4j.Logger; +import org.bukkit.entity.Player; 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.PlayerLoginEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.metadata.MetadataValue; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.framework.qual.DefaultQualifier; @@ -75,7 +77,19 @@ public void onLogin(final PlayerLoginEvent event) { @EventHandler(priority = EventPriority.LOWEST) public void onJoinEarly(final PlayerJoinEvent event) { - this.messaging.get().queuePacket(() -> this.packetFactory.addLocalPlayerPacket(event.getPlayer().getUniqueId(), event.getPlayer().getName())); + this.messaging.get().queuePacket(() -> this.packetFactory.addLocalPlayerPacket( + event.getPlayer().getUniqueId(), + event.getPlayer().getName(), + vanished(event.getPlayer()) + )); + } + + // Supported by PremiumVanish, SuperVanish, VanishNoPacket. Mirrors CarbonPlayerPaper#vanished. + // Runtime toggles are propagated by PaperVanishListener; this only seeds the state on join. + private static boolean vanished(final Player player) { + return player.getMetadata("vanished").stream() + .filter(value -> value.value() instanceof Boolean) + .anyMatch(MetadataValue::asBoolean); } @EventHandler(priority = EventPriority.HIGH)