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)