diff --git a/src/main/java/net/kewwbec/networkcore/NetworkCore.java b/src/main/java/net/kewwbec/networkcore/NetworkCore.java index 7082e73..7ce99e1 100644 --- a/src/main/java/net/kewwbec/networkcore/NetworkCore.java +++ b/src/main/java/net/kewwbec/networkcore/NetworkCore.java @@ -13,6 +13,7 @@ import net.kewwbec.networkcore.api.MetricsService; import net.kewwbec.networkcore.api.PlayerPresence; import net.kewwbec.networkcore.api.RpcClient; import net.kewwbec.networkcore.api.ServerRegistry; +import net.kewwbec.networkcore.api.VanishService; import net.kewwbec.networkcore.api.events.ServerLeftNetworkEvent; import net.kewwbec.networkcore.bus.RedisMessageBus; import net.kewwbec.networkcore.command.NetworkCoreCommand; @@ -46,6 +47,11 @@ import net.kewwbec.networkcore.xp.XpSchemaBootstrap; import net.kewwbec.networkcore.presence.PlayerPresenceService; import net.kewwbec.networkcore.registry.RedisServerRegistry; import net.kewwbec.networkcore.rpc.RpcClientImpl; +import net.kewwbec.networkcore.vanish.PlayerListVanishFilter; +import net.kewwbec.networkcore.vanish.VanishEnforcer; +import net.kewwbec.networkcore.vanish.VanishServiceImpl; + +import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -75,6 +81,7 @@ public final class NetworkCore extends JavaPlugin { @Nullable private RefRequestService refRequestService; @Nullable private LockService lockService; @Nullable private ShutdownHandoff shutdownHandoff; + @Nullable private VanishServiceImpl vanishService; public NetworkCore(@Nonnull JavaPluginInit init) { super(init); @@ -164,6 +171,13 @@ public final class NetworkCore extends JavaPlugin { this.alertService = new AlertService(messageBus, serverId); alertService.start(); + this.vanishService = new VanishServiceImpl(messageBus, serverId); + vanishService.start(); + services.register(VanishService.class, vanishService); + VanishEnforcer vanishEnforcer = new VanishEnforcer(vanishService); + getEventRegistry().registerGlobal(PlayerReadyEvent.class, vanishEnforcer::onPlayerReady); + new PlayerListVanishFilter(vanishService).register(); + this.refRequestService = new RefRequestService(messageBus, serverId); refRequestService.start(); @@ -185,7 +199,7 @@ public final class NetworkCore extends JavaPlugin { }); getCommandRegistry().registerCommand(new AlertCommand(alertService)); - getCommandRegistry().registerCommand(new GlistCommand(serverRegistry, presenceService)); + getCommandRegistry().registerCommand(new GlistCommand(serverRegistry, presenceService, vanishService)); getCommandRegistry().registerCommand(new HubCommand(lobbyPicker, refRequestService, serverId)); getCommandRegistry().registerCommand(new SendCommand(serverRegistry, presenceService, refRequestService)); getCommandRegistry().registerCommand(new SendAllCommand(serverRegistry, presenceService, refRequestService, serverId)); @@ -227,6 +241,10 @@ public final class NetworkCore extends JavaPlugin { try { alertService.stop(); } catch (RuntimeException ignored) {} alertService = null; } + if (vanishService != null) { + try { vanishService.stop(); } catch (RuntimeException ignored) {} + vanishService = null; + } if (presenceService != null) { try { presenceService.stop(); } catch (RuntimeException ignored) {} presenceService = null; @@ -288,6 +306,8 @@ public final class NetworkCore extends JavaPlugin { @Nullable public NetworkXp getNetworkXp() { return services.get(NetworkXp.class); } @Nullable + public VanishService getVanishService() { return services.get(VanishService.class); } + @Nullable public RefRequestService getRefRequestService() { return refRequestService; } @Nullable diff --git a/src/main/java/net/kewwbec/networkcore/api/VanishService.java b/src/main/java/net/kewwbec/networkcore/api/VanishService.java new file mode 100644 index 0000000..5daefbf --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/api/VanishService.java @@ -0,0 +1,48 @@ +package net.kewwbec.networkcore.api; + +import com.hypixel.hytale.server.core.universe.PlayerRef; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; + +/** + * Cross-server vanish state. Source of truth shared via the {@link MessageBus}; + * each server caches and enforces locally (HiddenPlayersManager + join-broadcast + * suppression by consuming plugins). + * + *

Players with the {@code networkstaff.vanish} permission can see vanished + * players regardless. Everyone else sees them as if they weren't there.

+ */ +public interface VanishService { + + boolean isVanished(@Nonnull UUID playerUuid); + + /** + * Toggle vanish state for {@code playerUuid}. Persists to DB (if configured) + * and broadcasts to other servers on the bus. + */ + void setVanished(@Nonnull UUID playerUuid, boolean vanished); + + /** Snapshot of every UUID currently marked vanished, across the network. */ + @Nonnull + Set vanishedPlayers(); + + /** + * Convenience check for "should I pretend this target is offline when showing + * results to {@code viewer}?" Returns true iff {@code target} is vanished and + * {@code viewer} lacks {@code networkstaff.vanish}. Null viewer is treated as + * non-staff (defensive — assume hidden). + */ + boolean isHiddenFrom(@Nonnull UUID target, @Nullable PlayerRef viewer); + + /** + * Subscribe to vanish state changes. Listener receives {@code (uuid, vanishedNow)} + * for every toggle, whether the change originated locally or arrived via the bus + * from another server. Fires on the bus-handling thread; consumers that need the + * world thread must hop themselves. + */ + void addListener(@Nonnull BiConsumer listener); +} diff --git a/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java b/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java index 72c1d75..6c0d237 100644 --- a/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java +++ b/src/main/java/net/kewwbec/networkcore/command/network/GlistCommand.java @@ -15,6 +15,8 @@ import net.kewwbec.networkcore.api.PlayerLocation; import net.kewwbec.networkcore.api.PlayerPresence; import net.kewwbec.networkcore.api.ServerInfo; import net.kewwbec.networkcore.api.ServerRegistry; +import net.kewwbec.networkcore.api.VanishService; +import javax.annotation.Nullable; import javax.annotation.Nonnull; import java.awt.Color; @@ -36,12 +38,16 @@ public final class GlistCommand extends AbstractPlayerCommand { private final ServerRegistry registry; private final PlayerPresence presence; + @Nullable private final VanishService vanish; private final OptionalArg filterArg = withOptionalArg("filter", "Server id or role to filter by", ArgTypes.STRING); - public GlistCommand(@Nonnull ServerRegistry registry, @Nonnull PlayerPresence presence) { + public GlistCommand(@Nonnull ServerRegistry registry, + @Nonnull PlayerPresence presence, + @Nullable VanishService vanish) { super("glist", "List online players, optionally filtered by server id or role"); this.registry = registry; this.presence = presence; + this.vanish = vanish; requirePermission(NetworkCorePermissions.GLIST); } @@ -60,11 +66,13 @@ public final class GlistCommand extends AbstractPlayerCommand { Set includedIds = new LinkedHashSet<>(); for (ServerInfo s : servers) includedIds.add(s.id()); + boolean canSeeVanished = sender.hasPermission(net.kewwbec.networkcore.vanish.VanishEnforcer.PERM_SEE); Map> grouped = new LinkedHashMap<>(); for (ServerInfo s : servers) grouped.put(s.id(), new ArrayList<>()); int filteredOnline = 0; for (PlayerLocation loc : presence.all()) { if (!includedIds.contains(loc.serverId())) continue; + if (!canSeeVanished && vanish != null && vanish.isVanished(loc.uuid())) continue; grouped.computeIfAbsent(loc.serverId(), k -> new ArrayList<>()).add(loc); filteredOnline++; } diff --git a/src/main/java/net/kewwbec/networkcore/vanish/PlayerListVanishFilter.java b/src/main/java/net/kewwbec/networkcore/vanish/PlayerListVanishFilter.java new file mode 100644 index 0000000..19c5574 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/vanish/PlayerListVanishFilter.java @@ -0,0 +1,85 @@ +package net.kewwbec.networkcore.vanish; + +import com.hypixel.hytale.protocol.Packet; +import com.hypixel.hytale.protocol.packets.interface_.AddToServerPlayerList; +import com.hypixel.hytale.protocol.packets.interface_.ServerPlayerListPlayer; +import com.hypixel.hytale.protocol.packets.interface_.ServerPlayerListUpdate; +import com.hypixel.hytale.protocol.packets.interface_.UpdateServerPlayerList; +import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; +import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.networkcore.api.VanishService; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.List; + +/** + * Outbound packet filter that strips vanished players from the client-visible + * tab list. Runs for every outbound {@link AddToServerPlayerList} and + * {@link UpdateServerPlayerList}; for non-staff recipients, drops any entries + * for vanished UUIDs. If the resulting array is empty the packet is cancelled + * entirely. + * + *

For mid-session toggles, see {@link VanishEnforcer#onVanishChanged} — + * that side pushes explicit {@code RemoveFromServerPlayerList} / + * {@code AddToServerPlayerList} to non-staff clients so the list updates + * without waiting for the next engine push.

+ */ +public final class PlayerListVanishFilter implements PlayerPacketFilter { + + private final VanishService vanish; + + public PlayerListVanishFilter(@Nonnull VanishService vanish) { + this.vanish = vanish; + } + + public void register() { + PacketAdapters.registerOutbound(this); + } + + @Override + public boolean test(@Nonnull PlayerRef recipient, @Nonnull Packet packet) { + if (recipient.hasPermission(VanishEnforcer.PERM_SEE)) return false; + + if (packet instanceof AddToServerPlayerList add) { + return filterAdd(add); + } + if (packet instanceof UpdateServerPlayerList upd) { + return filterUpdate(upd); + } + return false; + } + + private boolean filterAdd(@Nonnull AddToServerPlayerList add) { + ServerPlayerListPlayer[] players = add.players; + if (players == null || players.length == 0) return false; + List kept = new ArrayList<>(players.length); + for (ServerPlayerListPlayer p : players) { + if (p == null || p.uuid == null) continue; + if (vanish.isVanished(p.uuid)) continue; + kept.add(p); + } + if (kept.isEmpty()) return true; + if (kept.size() != players.length) { + add.players = kept.toArray(new ServerPlayerListPlayer[0]); + } + return false; + } + + private boolean filterUpdate(@Nonnull UpdateServerPlayerList upd) { + ServerPlayerListUpdate[] players = upd.players; + if (players == null || players.length == 0) return false; + List kept = new ArrayList<>(players.length); + for (ServerPlayerListUpdate u : players) { + if (u == null || u.uuid == null) continue; + if (vanish.isVanished(u.uuid)) continue; + kept.add(u); + } + if (kept.isEmpty()) return true; + if (kept.size() != players.length) { + upd.players = kept.toArray(new ServerPlayerListUpdate[0]); + } + return false; + } +} diff --git a/src/main/java/net/kewwbec/networkcore/vanish/VanishChangePayload.java b/src/main/java/net/kewwbec/networkcore/vanish/VanishChangePayload.java new file mode 100644 index 0000000..372b24b --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/vanish/VanishChangePayload.java @@ -0,0 +1,19 @@ +package net.kewwbec.networkcore.vanish; + +/** + * Bus payload broadcast on every vanish toggle. Carries enough state for any + * server to update its cache and enforce locally. + */ +public final class VanishChangePayload { + + public static final String CHANNEL = "network:vanish"; + + /** UUID being vanished/unvanished. */ + public String uuid; + /** New state — true=vanished, false=unvanished. */ + public boolean vanished; + /** Origin server id (for echo suppression). */ + public String serverId; + /** Timestamp (ms since epoch) for staleness checks if we ever add them. */ + public long ts; +} diff --git a/src/main/java/net/kewwbec/networkcore/vanish/VanishEnforcer.java b/src/main/java/net/kewwbec/networkcore/vanish/VanishEnforcer.java new file mode 100644 index 0000000..1ba47c6 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/vanish/VanishEnforcer.java @@ -0,0 +1,114 @@ +package net.kewwbec.networkcore.vanish; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.protocol.packets.interface_.AddToServerPlayerList; +import com.hypixel.hytale.protocol.packets.interface_.RemoveFromServerPlayerList; +import com.hypixel.hytale.protocol.packets.interface_.ServerPlayerListPlayer; +import com.hypixel.hytale.server.core.entity.entities.player.HiddenPlayersManager; +import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; +import com.hypixel.hytale.server.core.io.PacketHandler; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.Universe; + +import javax.annotation.Nonnull; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Per-server enforcement of vanish state via {@link HiddenPlayersManager}. + * Wired by NetworkCore: on player join we hide existing vanished players from + * non-staff arrivals, and on vanish toggles (local or remote via bus) we update + * every online player's visibility set. + */ +public final class VanishEnforcer { + + /** Anyone with this perm sees vanished players and can use /vanish. */ + public static final String PERM_SEE = "networkstaff.vanish"; + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final VanishServiceImpl vanish; + + public VanishEnforcer(@Nonnull VanishServiceImpl vanish) { + this.vanish = vanish; + vanish.addListener(this::onVanishChanged); + } + + /** Called by NetworkCore on every PlayerReadyEvent. */ + public void onPlayerReady(@Nonnull PlayerReadyEvent event) { + com.hypixel.hytale.component.Ref ref = + event.getPlayerRef(); + if (ref == null || !ref.isValid()) return; + com.hypixel.hytale.component.Store store = + ref.getStore(); + if (store == null) return; + PlayerRef newcomer = store.getComponent(ref, PlayerRef.getComponentType()); + if (newcomer == null) return; + + UUID newcomerUuid = newcomer.getUuid(); + boolean newcomerCanSeeVanished = newcomer.hasPermission(PERM_SEE); + + if (!newcomerCanSeeVanished) { + HiddenPlayersManager hpm = newcomer.getHiddenPlayersManager(); + if (hpm != null) { + for (UUID vanishedUuid : vanish.vanishedPlayers()) { + if (vanishedUuid.equals(newcomerUuid)) continue; + hpm.hidePlayer(vanishedUuid); + } + } + } + + if (vanish.isVanished(newcomerUuid)) { + Universe universe = Universe.get(); + if (universe == null) return; + for (PlayerRef other : universe.getPlayers()) { + if (other.getUuid().equals(newcomerUuid)) continue; + if (other.hasPermission(PERM_SEE)) continue; + HiddenPlayersManager hpm = other.getHiddenPlayersManager(); + if (hpm != null) hpm.hidePlayer(newcomerUuid); + } + } + } + + private void onVanishChanged(@Nonnull UUID uuid, boolean vanishedNow) { + Universe universe = Universe.get(); + if (universe == null) return; + try { + PlayerRef subject = null; + for (PlayerRef p : universe.getPlayers()) { + if (p.getUuid().equals(uuid)) { subject = p; break; } + } + for (PlayerRef other : universe.getPlayers()) { + if (other.getUuid().equals(uuid)) continue; + HiddenPlayersManager hpm = other.getHiddenPlayersManager(); + if (hpm != null) { + if (other.hasPermission(PERM_SEE)) { + hpm.showPlayer(uuid); + } else if (vanishedNow) { + hpm.hidePlayer(uuid); + } else { + hpm.showPlayer(uuid); + } + } + if (other.hasPermission(PERM_SEE)) continue; + PacketHandler ph = other.getPacketHandler(); + if (ph == null) continue; + try { + if (vanishedNow) { + ph.write(new RemoveFromServerPlayerList(new UUID[]{uuid})); + } else if (subject != null) { + ServerPlayerListPlayer entry = new ServerPlayerListPlayer( + subject.getUuid(), + subject.getUsername(), + subject.getWorldUuid(), + 0); + ph.write(new AddToServerPlayerList(new ServerPlayerListPlayer[]{entry})); + } + } catch (RuntimeException ignored) { + } + } + } catch (RuntimeException e) { + LOGGER.at(Level.WARNING).log("VanishEnforcer onVanishChanged threw: %s", e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/networkcore/vanish/VanishServiceImpl.java b/src/main/java/net/kewwbec/networkcore/vanish/VanishServiceImpl.java new file mode 100644 index 0000000..1a1ed88 --- /dev/null +++ b/src/main/java/net/kewwbec/networkcore/vanish/VanishServiceImpl.java @@ -0,0 +1,115 @@ +package net.kewwbec.networkcore.vanish; + +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.networkcore.api.MessageBus; +import net.kewwbec.networkcore.api.VanishService; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; +import java.util.logging.Level; + +/** + * In-memory vanish cache, kept consistent across the network via the + * {@link MessageBus}. State is not persisted — vanish ends at server restart + * (intentional: staff explicitly turn it on each session). + */ +public final class VanishServiceImpl implements VanishService { + + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + + private final MessageBus bus; + private final String selfServerId; + private final Set vanished = ConcurrentHashMap.newKeySet(); + /** Listeners notified on every change, local or remote-via-bus. */ + private final List> listeners = new CopyOnWriteArrayList<>(); + @Nullable private MessageBus.Subscription sub; + + public VanishServiceImpl(@Nonnull MessageBus bus, @Nonnull String selfServerId) { + this.bus = bus; + this.selfServerId = selfServerId; + } + + public void start() { + sub = bus.subscribe(VanishChangePayload.CHANNEL, VanishChangePayload.class, this::onRemote); + } + + public void stop() { + if (sub != null) { + try { sub.close(); } catch (RuntimeException ignored) {} + sub = null; + } + vanished.clear(); + } + + @Override + public void addListener(@Nonnull BiConsumer listener) { + listeners.add(listener); + } + + @Override + public boolean isVanished(@Nonnull UUID playerUuid) { + return vanished.contains(playerUuid); + } + + @Override + public void setVanished(@Nonnull UUID playerUuid, boolean vanishedNow) { + boolean changed = vanishedNow ? vanished.add(playerUuid) : vanished.remove(playerUuid); + if (!changed) return; + fireLocal(playerUuid, vanishedNow); + publish(playerUuid, vanishedNow); + } + + @Override + @Nonnull + public Set vanishedPlayers() { + return Collections.unmodifiableSet(vanished); + } + + @Override + public boolean isHiddenFrom(@Nonnull UUID target, @Nullable PlayerRef viewer) { + if (!vanished.contains(target)) return false; + if (viewer == null) return true; + if (viewer.getUuid().equals(target)) return false; + return !viewer.hasPermission(VanishEnforcer.PERM_SEE); + } + + private void onRemote(@Nonnull VanishChangePayload p) { + if (p.uuid == null || p.serverId == null) return; + if (selfServerId.equals(p.serverId)) return; + UUID uuid; + try { uuid = UUID.fromString(p.uuid); } catch (IllegalArgumentException e) { return; } + boolean changed = p.vanished ? vanished.add(uuid) : vanished.remove(uuid); + if (changed) fireLocal(uuid, p.vanished); + } + + private void publish(@Nonnull UUID uuid, boolean vanishedNow) { + try { + VanishChangePayload p = new VanishChangePayload(); + p.uuid = uuid.toString(); + p.vanished = vanishedNow; + p.serverId = selfServerId; + p.ts = System.currentTimeMillis(); + bus.publish(VanishChangePayload.CHANNEL, p); + } catch (RuntimeException e) { + LOGGER.at(Level.WARNING).log("Vanish publish failed: %s", e.getMessage()); + } + } + + private void fireLocal(@Nonnull UUID uuid, boolean vanishedNow) { + for (BiConsumer listener : listeners) { + try { + listener.accept(uuid, vanishedNow); + } catch (RuntimeException e) { + LOGGER.at(Level.WARNING).log("Vanish listener threw: %s", e.getMessage()); + } + } + } +}