feat(stealth): conceal identity for prepared sessions
CI / build (push) Successful in 5m4s
Release / release (push) Successful in 6m13s

This commit is contained in:
dmg
2026-08-15 00:16:09 -04:00
parent 90851a0d87
commit 6071bfbdce
16 changed files with 510 additions and 18 deletions
@@ -0,0 +1,75 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;
/** Uses scoreboard metadata and tab-only packets while leaving player entities visible. */
public final class BukkitIdentityPresentation implements IdentityPresentation {
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
private final Scoreboard scoreboard;
private final TabListController tabLists;
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>();
public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers,
Scoreboard scoreboard,
TabListController tabLists) {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
this.scoreboard = Objects.requireNonNull(scoreboard, "scoreboard");
this.tabLists = Objects.requireNonNull(tabLists, "tabLists");
}
@Override
public void conceal(Player player) {
concealedPlayers.put(player.getUniqueId(), player);
Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team == null) {
team = scoreboard.registerNewTeam(teamName(player.getUniqueId()));
}
team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);
team.addEntry(player.getName());
for (Player observer : onlinePlayers.get()) {
if (!observer.getUniqueId().equals(player.getUniqueId())) {
tabLists.remove(observer, player.getUniqueId());
}
}
}
@Override
public void reveal(Player player) {
boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null;
Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team != null) {
wasConcealed = true;
team.removeEntry(player.getName());
team.unregister();
}
if (wasConcealed) {
for (Player observer : onlinePlayers.get()) {
if (!observer.getUniqueId().equals(player.getUniqueId())) {
tabLists.add(observer, player);
}
}
}
}
@Override
public void refreshForObserver(Player observer) {
for (UUID concealedPlayerId : concealedPlayers.keySet()) {
if (!observer.getUniqueId().equals(concealedPlayerId)) {
tabLists.remove(observer, concealedPlayerId);
}
}
}
public static String teamName(UUID playerId) {
return "stlth" + playerId.toString().replace("-", "").substring(0, 11);
}
}
@@ -0,0 +1,12 @@
package games.dmg.spigotstealth;
import org.bukkit.entity.Player;
/** Presentation boundary that changes identity metadata without hiding the physical player entity. */
public interface IdentityPresentation {
void conceal(Player player);
void reveal(Player player);
void refreshForObserver(Player observer);
}
@@ -8,7 +8,6 @@ import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.potion.PotionEffectType;
/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */
@@ -50,8 +49,4 @@ public final class InvisibilityEffectListener implements Listener {
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
progression.stop(event.getPlayer().getUniqueId());
}
}
@@ -0,0 +1,52 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.ProtocolManager;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.wrappers.EnumWrappers;
import com.comphenix.protocol.wrappers.PlayerInfoData;
import com.comphenix.protocol.wrappers.WrappedChatComponent;
import com.comphenix.protocol.wrappers.WrappedGameProfile;
import com.comphenix.protocol.wrappers.WrappedRemoteChatSessionData;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import org.bukkit.entity.Player;
/** ProtocolLib adapter for tab-list-only removal and restoration packets. */
public final class ProtocolLibTabListController implements TabListController {
private final ProtocolManager protocolManager;
public ProtocolLibTabListController(ProtocolManager protocolManager) {
this.protocolManager = Objects.requireNonNull(protocolManager, "protocolManager");
}
@Override
public void remove(Player observer, UUID targetPlayerId) {
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO_REMOVE);
packet.getUUIDLists().write(0, List.of(targetPlayerId));
protocolManager.sendServerPacket(observer, packet);
}
@Override
public void add(Player observer, Player target) {
PacketContainer packet = protocolManager.createPacket(PacketType.Play.Server.PLAYER_INFO);
packet.getPlayerInfoActions().write(0, EnumSet.of(
EnumWrappers.PlayerInfoAction.ADD_PLAYER,
EnumWrappers.PlayerInfoAction.UPDATE_GAME_MODE,
EnumWrappers.PlayerInfoAction.UPDATE_LISTED,
EnumWrappers.PlayerInfoAction.UPDATE_LATENCY,
EnumWrappers.PlayerInfoAction.UPDATE_DISPLAY_NAME));
PlayerInfoData data = new PlayerInfoData(
target.getUniqueId(),
target.getPing(),
true,
EnumWrappers.NativeGameMode.fromBukkit(target.getGameMode()),
WrappedGameProfile.fromPlayer(target),
WrappedChatComponent.fromText(target.getPlayerListName()),
(WrappedRemoteChatSessionData) null);
packet.getPlayerInfoDataLists().write(1, List.of(data));
protocolManager.sendServerPacket(observer, packet);
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.ProtocolLibrary;
import java.nio.file.Path;
import java.time.Clock;
import java.util.Objects;
@@ -12,6 +13,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
private CompletableFuture<StealthStateManager> stateManagerFuture;
private StealthSettings settings;
private QualifyingInvisibilityService progression;
private StealthSessionService sessions;
private IdentityPresentation identityPresentation;
@Override
public void onEnable() {
@@ -38,6 +41,14 @@ public final class SpigotStealthPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (identityPresentation != null && sessions != null) {
for (org.bukkit.entity.Player player : getServer().getOnlinePlayers()) {
if (sessions.isConcealed(player.getUniqueId())) {
identityPresentation.reveal(player);
sessions.endConcealment(player.getUniqueId());
}
}
}
if (progression != null) {
progression.stopAll().join();
}
@@ -57,8 +68,15 @@ public final class SpigotStealthPlugin extends JavaPlugin {
settings.unlockedMessage());
progression = new QualifyingInvisibilityService(
manager, settings.unlockThreshold(), System::nanoTime, notifier);
sessions = new StealthSessionService(manager, progression);
identityPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(ProtocolLibrary.getProtocolManager()));
getServer().getPluginManager().registerEvents(
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents(
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
Objects.requireNonNull(getCommand("stealth"), "stealth command")
.setExecutor(new StealthCommand(manager, progression, settings));
getServer().getScheduler().runTaskTimer(this, ignored -> {
@@ -66,6 +84,11 @@ public final class SpigotStealthPlugin extends JavaPlugin {
logSaveFailure(progression.checkpoint(playerId));
}
}, 20L, 20L);
getServer().getScheduler().runTaskTimer(this, ignored -> {
for (org.bukkit.entity.Player observer : getServer().getOnlinePlayers()) {
identityPresentation.refreshForObserver(observer);
}
}, 20L, 20L);
getServer().getScheduler().runTaskTimer(this, ignored -> logSaveFailure(manager.save()), 6000L, 6000L);
getLogger().info("Spigot Stealth enabled");
}
@@ -0,0 +1,53 @@
package games.dmg.spigotstealth;
import java.util.Objects;
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.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
/** Applies session transitions and identity presentation on Bukkit lifecycle events. */
public final class StealthSessionListener implements Listener {
private final StealthSessionService sessions;
private final IdentityPresentation presentation;
private final String concealedMessage;
public StealthSessionListener(
StealthSessionService sessions,
IdentityPresentation presentation,
String concealedMessage) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
this.presentation = Objects.requireNonNull(presentation, "presentation");
this.concealedMessage = Objects.requireNonNull(concealedMessage, "concealedMessage");
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
StealthSessionService.LoginTransition transition = sessions.login(player.getUniqueId(), player.getName());
if (transition.concealed()) {
event.setJoinMessage(null);
presentation.conceal(player);
player.sendMessage(concealedMessage);
} else {
presentation.reveal(player);
}
presentation.refreshForObserver(player);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
presentation.reveal(event.getPlayer());
sessions.disconnect(event.getPlayer().getUniqueId());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) {
if (sessions.isConcealed(event.getPlayer().getUniqueId())) {
presentation.conceal(event.getPlayer());
}
}
}
@@ -0,0 +1,52 @@
package games.dmg.spigotstealth;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
/** Owns prepared-login and current concealed-session state transitions. */
public final class StealthSessionService {
private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression;
public StealthSessionService(
StealthStateManager stateManager,
QualifyingInvisibilityService progression) {
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.progression = Objects.requireNonNull(progression, "progression");
}
public CompletableFuture<Void> disconnect(UUID playerId) {
boolean qualifyingAtDisconnect = progression.isQualifying(playerId);
return progression.stop(playerId).thenCompose(ignored -> stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
boolean prepareNextLogin = qualifyingAtDisconnect && player.unlocked();
return state.withPlayer(player.withSession(prepareNextLogin, false));
}));
}
public LoginTransition login(UUID playerId, String playerName) {
AtomicBoolean concealed = new AtomicBoolean();
CompletableFuture<Void> saved = stateManager.update(state -> {
PlayerStealthState player = state.player(playerId).withLastKnownName(playerName);
boolean conceal = player.unlocked() && player.preparedLogin();
concealed.set(conceal);
return state.withPlayer(player.withSession(false, conceal).withQualifyingSince(null));
});
return new LoginTransition(concealed.get(), saved);
}
public CompletableFuture<Void> endConcealment(UUID playerId) {
return stateManager.update(state -> {
PlayerStealthState player = state.player(playerId);
return state.withPlayer(player.withSession(player.preparedLogin(), false));
});
}
public boolean isConcealed(UUID playerId) {
return stateManager.snapshot().player(playerId).concealed();
}
public record LoginTransition(boolean concealed, CompletableFuture<Void> saved) { }
}
@@ -0,0 +1,11 @@
package games.dmg.spigotstealth;
import java.util.UUID;
import org.bukkit.entity.Player;
/** Packet-level tab-list operations that do not hide the physical player entity. */
public interface TabListController {
void remove(Player observer, UUID targetPlayerId);
void add(Player observer, Player target);
}
+1
View File
@@ -4,6 +4,7 @@ main: games.dmg.spigotstealth.SpigotStealthPlugin
api-version: "1.20"
description: Rewards invisibility potion use with identity-concealed sessions.
author: dmg.games
depend: [ProtocolLib]
commands:
stealth:
description: View and use Spigot Stealth features.