feat(stealth): conceal identity for prepared sessions
This commit is contained in:
@@ -24,8 +24,10 @@ tasks.withType<JavaCompile>().configureEach {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||||
|
compileOnly("net.dmulloy2:ProtocolLib:5.4.0")
|
||||||
|
|
||||||
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||||
|
testImplementation("net.dmulloy2:ProtocolLib:5.4.0")
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## 2026-08-14
|
## 2026-08-14
|
||||||
|
|
||||||
|
- **Completion**: Completed US-002 with single-use durable prepared logins, join-announcement suppression, ProtocolLib tab-only removal, scoreboard overhead-name suppression, visible physical entities, periodic observer refresh, respawn restoration, and disconnect or disable cleanup; verified the full Gradle build.
|
||||||
|
- **Implementation**: Began US-002 with test-first prepared-login consumption, session-scoped concealment, join suppression, tab removal, and overhead-name presentation.
|
||||||
- **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build.
|
- **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build.
|
||||||
- **Implementation**: Began US-003 with player-facing progress command and human-readable duration tests.
|
- **Implementation**: Began US-003 with player-facing progress command and human-readable duration tests.
|
||||||
- **Completion**: Completed US-001 with direct potion-drink effect attribution, monotonic elapsed-time accumulation, safe refresh and stop transitions, durable UUID progress, exact-once unlocking, and title plus chat presentation; verified the full Gradle build.
|
- **Completion**: Completed US-001 with direct potion-drink effect attribution, monotonic elapsed-time accumulation, safe refresh and stop transitions, durable UUID progress, exact-once unlocking, and title plus chat presentation; verified the full Gradle build.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-002: Rejoin without a visible identity"
|
title: "US-002: Rejoin without a visible identity"
|
||||||
description: Let an unlocked player turn a qualifying invisible disconnect into one identity-concealed session.
|
description: Let an unlocked player turn a qualifying invisible disconnect into one identity-concealed session.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-002: Rejoin without a visible identity
|
# US-002: Rejoin without a visible identity
|
||||||
@@ -11,18 +11,22 @@ As an **unlocked player**, I want to disconnect while invisibility from a potion
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] An unlocked player becomes eligible for a concealed login only by disconnecting while an invisibility effect from a potion they directly drank remains active.
|
- [x] An unlocked player becomes eligible for a concealed login only by disconnecting while an invisibility effect from a potion they directly drank remains active.
|
||||||
- [ ] A player who has not unlocked stealth cannot prepare a concealed login.
|
- [x] A player who has not unlocked stealth cannot prepare a concealed login.
|
||||||
- [ ] An ordinary disconnect without an active qualifying effect clears any preparation for the next login.
|
- [x] An ordinary disconnect without an active qualifying effect clears any preparation for the next login.
|
||||||
- [ ] On a prepared login, no public join announcement is shown.
|
- [x] On a prepared login, no public join announcement is shown.
|
||||||
- [ ] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists.
|
- [x] Throughout the concealed session, the player is absent from every other player's tab list, including administrators' tab lists.
|
||||||
- [ ] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
|
- [x] Throughout the concealed session, no overhead name tag identifies the player to any other player, including administrators.
|
||||||
- [ ] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior.
|
- [x] The concealed player's physical character remains visible in the world and retains ordinary movement, interaction, combat, and permission behavior.
|
||||||
- [ ] The concealed player receives a private message explaining that stealth is active for the session.
|
- [x] The concealed player receives a private message explaining that stealth is active for the session.
|
||||||
- [ ] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events.
|
- [x] Concealment lasts until the player disconnects and is handled predictably across death and plugin reload or disable events.
|
||||||
- [ ] Disconnecting consumes the current concealed session; another concealed login requires another qualifying potion and qualifying disconnect.
|
- [x] Disconnecting consumes the current concealed session; another concealed login requires another qualifying potion and qualifying disconnect.
|
||||||
- [ ] Merely owning the unlock never conceals an ordinary login or carries concealment automatically into a later session.
|
- [x] Merely owning the unlock never conceals an ordinary login or carries concealment automatically into a later session.
|
||||||
- [ ] Prepared-login state survives a server restart between the qualifying disconnect and the next login.
|
- [x] Prepared-login state survives a server restart between the qualifying disconnect and the next login.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify unlocked and locked disconnect transitions, ordinary-disconnect clearing, one-login consumption, announcement suppression, private activation messaging, ordinary-login presentation, tab removal for existing and new observers, overhead-name suppression, and the absence of entity-hiding calls. ProtocolLib is declared as a required dependency, prepared state round trips through YAML, and `./gradlew clean check jar` passes.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -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.EventPriority;
|
||||||
import org.bukkit.event.Listener;
|
import org.bukkit.event.Listener;
|
||||||
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||||
import org.bukkit.event.player.PlayerQuitEvent;
|
|
||||||
import org.bukkit.potion.PotionEffectType;
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */
|
/** 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;
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import com.comphenix.protocol.ProtocolLibrary;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -12,6 +13,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
||||||
private StealthSettings settings;
|
private StealthSettings settings;
|
||||||
private QualifyingInvisibilityService progression;
|
private QualifyingInvisibilityService progression;
|
||||||
|
private StealthSessionService sessions;
|
||||||
|
private IdentityPresentation identityPresentation;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -38,6 +41,14 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
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) {
|
if (progression != null) {
|
||||||
progression.stopAll().join();
|
progression.stopAll().join();
|
||||||
}
|
}
|
||||||
@@ -57,8 +68,15 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
settings.unlockedMessage());
|
settings.unlockedMessage());
|
||||||
progression = new QualifyingInvisibilityService(
|
progression = new QualifyingInvisibilityService(
|
||||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
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(
|
getServer().getPluginManager().registerEvents(
|
||||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||||
|
getServer().getPluginManager().registerEvents(
|
||||||
|
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
|
||||||
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
||||||
.setExecutor(new StealthCommand(manager, progression, settings));
|
.setExecutor(new StealthCommand(manager, progression, settings));
|
||||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||||
@@ -66,6 +84,11 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
logSaveFailure(progression.checkpoint(playerId));
|
logSaveFailure(progression.checkpoint(playerId));
|
||||||
}
|
}
|
||||||
}, 20L, 20L);
|
}, 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);
|
getServer().getScheduler().runTaskTimer(this, ignored -> logSaveFailure(manager.save()), 6000L, 6000L);
|
||||||
getLogger().info("Spigot Stealth enabled");
|
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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ main: games.dmg.spigotstealth.SpigotStealthPlugin
|
|||||||
api-version: "1.20"
|
api-version: "1.20"
|
||||||
description: Rewards invisibility potion use with identity-concealed sessions.
|
description: Rewards invisibility potion use with identity-concealed sessions.
|
||||||
author: dmg.games
|
author: dmg.games
|
||||||
|
depend: [ProtocolLib]
|
||||||
commands:
|
commands:
|
||||||
stealth:
|
stealth:
|
||||||
description: View and use Spigot Stealth features.
|
description: View and use Spigot Stealth features.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class BukkitIdentityPresentationTest {
|
||||||
|
@Test
|
||||||
|
void concealRemovesTabEntryAndHidesNameTagWithoutHidingEntity() {
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
Player target = player(targetId, "Alex");
|
||||||
|
Player observer = player(UUID.randomUUID(), "Morgan");
|
||||||
|
Scoreboard scoreboard = mock(Scoreboard.class);
|
||||||
|
Team team = mock(Team.class);
|
||||||
|
when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(null);
|
||||||
|
when(scoreboard.registerNewTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team);
|
||||||
|
TabListController tabLists = mock(TabListController.class);
|
||||||
|
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
|
||||||
|
() -> List.of(target, observer), scoreboard, tabLists);
|
||||||
|
|
||||||
|
presentation.conceal(target);
|
||||||
|
|
||||||
|
verify(team).setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);
|
||||||
|
verify(team).addEntry("Alex");
|
||||||
|
verify(tabLists).remove(observer, targetId);
|
||||||
|
verify(observer, never()).hidePlayer(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newObserverHasEveryConcealedPlayerRemovedFromTab() {
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
Player target = player(targetId, "Alex");
|
||||||
|
Player observer = player(UUID.randomUUID(), "Morgan");
|
||||||
|
Scoreboard scoreboard = mock(Scoreboard.class);
|
||||||
|
Team team = mock(Team.class);
|
||||||
|
when(scoreboard.getTeam(BukkitIdentityPresentation.teamName(targetId))).thenReturn(team);
|
||||||
|
TabListController tabLists = mock(TabListController.class);
|
||||||
|
BukkitIdentityPresentation presentation = new BukkitIdentityPresentation(
|
||||||
|
() -> List.of(target, observer), scoreboard, tabLists);
|
||||||
|
presentation.conceal(target);
|
||||||
|
|
||||||
|
presentation.refreshForObserver(observer);
|
||||||
|
|
||||||
|
verify(tabLists, org.mockito.Mockito.atLeastOnce()).remove(observer, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID playerId, String name) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn(name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ class PluginMetadataTest {
|
|||||||
assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main"));
|
assertEquals("games.dmg.spigotstealth.SpigotStealthPlugin", metadata.get("main"));
|
||||||
assertNotNull(metadata.get("commands"));
|
assertNotNull(metadata.get("commands"));
|
||||||
assertNotNull(metadata.get("permissions"));
|
assertNotNull(metadata.get("permissions"));
|
||||||
|
assertEquals(java.util.List.of("ProtocolLib"), metadata.get("depend"));
|
||||||
} catch (java.io.IOException exception) {
|
} catch (java.io.IOException exception) {
|
||||||
throw new AssertionError(exception);
|
throw new AssertionError(exception);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StealthSessionListenerTest {
|
||||||
|
@Test
|
||||||
|
void preparedLoginSuppressesAnnouncementAndConcealsIdentityForSession() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state.withPlayer(unlocked(playerId).withSession(true, false))).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
IdentityPresentation presentation = mock(IdentityPresentation.class);
|
||||||
|
StealthSessionListener listener = new StealthSessionListener(
|
||||||
|
new StealthSessionService(manager, progression), presentation, "Stealth active");
|
||||||
|
Player player = player(playerId);
|
||||||
|
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
|
||||||
|
listener.onJoin(event);
|
||||||
|
|
||||||
|
verify(event).setJoinMessage(null);
|
||||||
|
verify(presentation).conceal(player);
|
||||||
|
verify(player).sendMessage("Stealth active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinaryLoginRetainsAnnouncementAndIdentity() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
IdentityPresentation presentation = mock(IdentityPresentation.class);
|
||||||
|
StealthSessionListener listener = new StealthSessionListener(
|
||||||
|
new StealthSessionService(manager, progression), presentation, "Stealth active");
|
||||||
|
Player player = player(playerId);
|
||||||
|
PlayerJoinEvent event = mock(PlayerJoinEvent.class);
|
||||||
|
when(event.getPlayer()).thenReturn(player);
|
||||||
|
|
||||||
|
listener.onJoin(event);
|
||||||
|
|
||||||
|
verify(event, never()).setJoinMessage(null);
|
||||||
|
verify(presentation).reveal(player);
|
||||||
|
verify(player, never()).sendMessage("Stealth active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID playerId) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
when(player.getName()).thenReturn("Alex");
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerStealthState unlocked(UUID playerId) {
|
||||||
|
return new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StealthStateManager manager() {
|
||||||
|
StealthStateRepository repository = new StealthStateRepository() {
|
||||||
|
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
|
||||||
|
@Override public void save(PersistentStealthState state) { }
|
||||||
|
};
|
||||||
|
return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StealthSessionServiceTest {
|
||||||
|
@Test
|
||||||
|
void unlockedQualifyingDisconnectPreparesAndConsumesOneConcealedLogin() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state.withPlayer(unlocked(playerId))).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
progression.begin(playerId, "Alex", Instant.EPOCH).join();
|
||||||
|
StealthSessionService sessions = new StealthSessionService(manager, progression);
|
||||||
|
|
||||||
|
sessions.disconnect(playerId).join();
|
||||||
|
assertTrue(manager.snapshot().player(playerId).preparedLogin());
|
||||||
|
assertTrue(sessions.login(playerId, "Alex").concealed());
|
||||||
|
assertFalse(manager.snapshot().player(playerId).preparedLogin());
|
||||||
|
assertTrue(manager.snapshot().player(playerId).concealed());
|
||||||
|
|
||||||
|
sessions.disconnect(playerId).join();
|
||||||
|
assertFalse(sessions.login(playerId, "Alex").concealed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lockedPlayerCannotPrepareConcealedLogin() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
progression.begin(playerId, "Alex", Instant.EPOCH).join();
|
||||||
|
StealthSessionService sessions = new StealthSessionService(manager, progression);
|
||||||
|
sessions.disconnect(playerId).join();
|
||||||
|
assertFalse(sessions.login(playerId, "Alex").concealed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ordinaryDisconnectClearsEarlierPreparation() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state.withPlayer(unlocked(playerId).withSession(true, false))).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthSessionService sessions = new StealthSessionService(manager, progression);
|
||||||
|
sessions.disconnect(playerId).join();
|
||||||
|
assertFalse(manager.snapshot().player(playerId).preparedLogin());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerStealthState unlocked(UUID playerId) {
|
||||||
|
return new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StealthStateManager manager() {
|
||||||
|
StealthStateRepository repository = new StealthStateRepository() {
|
||||||
|
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
|
||||||
|
@Override public void save(PersistentStealthState state) { }
|
||||||
|
};
|
||||||
|
return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user