Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0da1508b6 | ||
|
|
2bcba5071f | ||
|
|
6071bfbdce | ||
|
|
90851a0d87 | ||
|
|
91c9e7b9ad |
@@ -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")
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Spigot Stealth Design Log
|
# Spigot Stealth Design Log
|
||||||
|
|
||||||
|
## 2026-09-04
|
||||||
|
|
||||||
|
- **Completion**: Extended US-003 and US-004 with contextual, prefix-filtered command completion that suppresses generic player suggestions, plus an administrative list of all known online and offline unlocked players; verified the complete Gradle build and OKF bundle.
|
||||||
|
|
||||||
## 2026-08-14
|
## 2026-08-14
|
||||||
|
|
||||||
|
- **Completion**: Completed US-004 with exact known online and offline targeting, complete status, idempotent grants, confirmed resets, active concealment discovery, operator-default permissions, asynchronous persisted replies, and console audits; verified the full Gradle build.
|
||||||
|
- **Implementation**: Began US-004 with test-first exact offline targeting, grant/reset transitions, concealed-player listing, permissions, confirmation, and audit logging.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **Implementation**: Began US-001 with monotonic-clock progression tests and potion-cause event attribution.
|
||||||
- **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build.
|
- **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build.
|
||||||
- **Implementation**: Began US-005 with test-first validated settings, UUID-keyed state, and defensive asynchronous persistence.
|
- **Implementation**: Began US-005 with test-first validated settings, UUID-keyed state, and defensive asynchronous persistence.
|
||||||
- **Completion**: Completed US-006 with a Java 17 Gradle build, strict compiler linting, Spigot API, JUnit 5, Mockito, plugin metadata, OKF validation, Gitea CI, conventional-commit checks, semantic releases, and versioned release assets; verified the full build and a `1.2.3` release JAR.
|
- **Completion**: Completed US-006 with a Java 17 Gradle build, strict compiler linting, Spigot API, JUnit 5, Mockito, plugin metadata, OKF validation, Gitea CI, conventional-commit checks, semantic releases, and versioned release assets; verified the full build and a `1.2.3` release JAR.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-001: Accumulate invisibility time and unlock stealth"
|
title: "US-001: Accumulate invisibility time and unlock stealth"
|
||||||
description: Reward sustained use of directly consumed invisibility potions with the stealth ability.
|
description: Reward sustained use of directly consumed invisibility potions with the stealth ability.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-001: Accumulate invisibility time and unlock stealth
|
# US-001: Accumulate invisibility time and unlock stealth
|
||||||
@@ -11,15 +11,19 @@ As a **player**, I want my qualifying invisibility time to accumulate so that su
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression.
|
- [x] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression.
|
||||||
- [ ] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute.
|
- [x] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute.
|
||||||
- [ ] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables.
|
- [x] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables.
|
||||||
- [ ] Refreshed or overlapping qualifying effects never count elapsed time more than once.
|
- [x] Refreshed or overlapping qualifying effects never count elapsed time more than once.
|
||||||
- [ ] Qualifying time accumulates across effects, sessions, and server restarts.
|
- [x] Qualifying time accumulates across effects, sessions, and server restarts.
|
||||||
- [ ] The ability unlocks when accumulated qualifying time reaches eight hours by default.
|
- [x] The ability unlocks when accumulated qualifying time reaches eight hours by default.
|
||||||
- [ ] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time.
|
- [x] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time.
|
||||||
- [ ] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability.
|
- [x] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability.
|
||||||
- [ ] Progress and unlock ownership are associated with the player's UUID rather than their current name.
|
- [x] Progress and unlock ownership are associated with the player's UUID rather than their current name.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify monotonic accumulation across intervals, refresh without duplicate time, exact-once threshold crossing with excess-time preservation, direct potion-drink cause filtering, non-qualifying replacement, UUID state, and online title and chat presentation. The complete `./gradlew clean check jar` lifecycle passes.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-003: Check personal stealth progress"
|
title: "US-003: Check personal stealth progress"
|
||||||
description: Show a player their accumulated qualifying invisibility time and stealth unlock status.
|
description: Show a player their accumulated qualifying invisibility time and stealth unlock status.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-003: Check personal stealth progress
|
# US-003: Check personal stealth progress
|
||||||
@@ -11,13 +11,19 @@ As a **player**, I want to check my stealth progress so that I know how close I
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `/stealth progress` reports the player's accumulated qualifying invisibility time.
|
- [x] `/stealth progress` reports the player's accumulated qualifying invisibility time.
|
||||||
- [ ] Before unlock, the command reports the configured target and remaining duration.
|
- [x] Before unlock, the command reports the configured target and remaining duration.
|
||||||
- [ ] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval.
|
- [x] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval.
|
||||||
- [ ] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login.
|
- [x] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login.
|
||||||
- [ ] Durations are presented in a concise, human-readable form.
|
- [x] Durations are presented in a concise, human-readable form.
|
||||||
- [ ] Repeated command use does not change progression or concealment state.
|
- [x] Repeated command use does not change progression or concealment state.
|
||||||
- [ ] The command has clear usage metadata and an appropriate player permission.
|
- [x] The command has clear usage metadata and an appropriate player permission.
|
||||||
|
- [x] `/stealth` tab-completes `progress`, filtered by the entered prefix.
|
||||||
|
- [x] Unsupported argument positions return no suggestions instead of Bukkit's generic player list.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, unlocked usage guidance, prefix-filtered completion, and suppression of irrelevant suggestions. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-004: Inspect and manage player stealth"
|
title: "US-004: Inspect and manage player stealth"
|
||||||
description: Give administrators durable controls for online and offline progression, unlocks, and active concealment.
|
description: Give administrators durable controls for online and offline progression, unlocks, and active concealment.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-004: Inspect and manage player stealth
|
# US-004: Inspect and manage player stealth
|
||||||
@@ -11,19 +11,29 @@ As a **server administrator**, I want to inspect and correct player stealth stat
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
|
- [x] `/stealthadmin status <player|uuid>` reports accumulated time, unlock status, prepared-login state, and current concealment state.
|
||||||
- [ ] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
|
- [x] Status inspection works for online players and known offline players selected by exact current or previously recorded name or UUID.
|
||||||
- [ ] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
|
- [x] `/stealthadmin grant <player|uuid>` grants the unlock to an online or known offline player without altering accumulated time unnecessarily.
|
||||||
- [ ] Granting an already-owned unlock is safe and clearly reports that no change was needed.
|
- [x] Granting an already-owned unlock is safe and clearly reports that no change was needed.
|
||||||
- [ ] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
|
- [x] Granting the unlock sends the normal full-screen title and chat notification when the target is online.
|
||||||
- [ ] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
|
- [x] `/stealthadmin reset <player|uuid> confirm` clears accumulated time, unlock ownership, prepared-login state, and current concealment for an online or known offline player.
|
||||||
- [ ] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
|
- [x] Reset requires explicit confirmation and safely restores an online concealed player to ordinary identity presentation.
|
||||||
- [ ] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
|
- [x] `/stealthadmin list` lists every currently online concealed player and clearly reports when there are none.
|
||||||
- [ ] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism.
|
- [x] Concealed players remain absent from administrators' ordinary tab lists and retain hidden overhead name tags; the admin command is the supported discovery mechanism.
|
||||||
- [ ] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
|
- [x] Commands clearly reject unknown, ambiguous, malformed, or otherwise invalid targets without creating unintended player records.
|
||||||
- [ ] Administrative inspection and modification require an operator-default administrative permission.
|
- [x] Administrative inspection and modification require an operator-default administrative permission.
|
||||||
- [ ] State-changing operations persist before success is reported and are safe under retries.
|
- [x] State-changing operations persist before success is reported and are safe under retries.
|
||||||
- [ ] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
|
- [x] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread.
|
||||||
|
- [x] `/stealthadmin` tab-completes `status`, `grant`, `reset`, and `list`, filtered by the entered prefix.
|
||||||
|
- [x] Target positions for `status`, `grant`, and `reset` suggest known player names, and reset's final argument suggests `confirm`.
|
||||||
|
- [x] Administrative completions require the administrative permission, and irrelevant positions return no suggestions instead of Bukkit's generic player list.
|
||||||
|
- [x] `/stealthadmin list unlocked` lists every known online or offline player with stealth unlocked.
|
||||||
|
- [x] The unlocked list is sorted case-insensitively, identifies nameless records by UUID, and clearly reports when it is empty.
|
||||||
|
- [x] Existing `/stealthadmin list` behavior continues to list currently concealed online players.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Automated tests verify exact offline name and UUID resolution, ambiguous and unknown rejection without record creation, complete status output, idempotent grants and notification, complete resets with presentation cleanup, online concealed-player filtering, online and offline unlocked-player listing, contextual permission-gated completion, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle 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,35 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Marshals unlock presentation onto the server thread. */
|
||||||
|
public final class BukkitUnlockNotifier implements Consumer<UUID> {
|
||||||
|
private final Function<UUID, Player> playerLookup;
|
||||||
|
private final Consumer<Runnable> mainThread;
|
||||||
|
private final String chatMessage;
|
||||||
|
|
||||||
|
public BukkitUnlockNotifier(
|
||||||
|
Function<UUID, Player> playerLookup,
|
||||||
|
Consumer<Runnable> mainThread,
|
||||||
|
String chatMessage) {
|
||||||
|
this.playerLookup = Objects.requireNonNull(playerLookup, "playerLookup");
|
||||||
|
this.mainThread = Objects.requireNonNull(mainThread, "mainThread");
|
||||||
|
this.chatMessage = Objects.requireNonNull(chatMessage, "chatMessage");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accept(UUID playerId) {
|
||||||
|
mainThread.accept(() -> {
|
||||||
|
Player player = playerLookup.apply(playerId);
|
||||||
|
if (player == null || !player.isOnline()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.sendTitle("Stealth Unlocked", "Your identity can now be concealed", 10, 100, 20);
|
||||||
|
player.sendMessage(chatMessage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
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.entity.EntityPotionEffectEvent;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
|
/** Attributes invisibility changes to directly consumed potions and closes intervals safely. */
|
||||||
|
public final class InvisibilityEffectListener implements Listener {
|
||||||
|
private final QualifyingInvisibilityService progression;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
public InvisibilityEffectListener(QualifyingInvisibilityService progression, Clock clock) {
|
||||||
|
this.progression = Objects.requireNonNull(progression, "progression");
|
||||||
|
this.clock = Objects.requireNonNull(clock, "clock");
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||||
|
public void onPotionEffect(EntityPotionEffectEvent event) {
|
||||||
|
if (!(event.getEntity() instanceof Player player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePotionEffect(
|
||||||
|
player,
|
||||||
|
PotionEffectType.INVISIBILITY.equals(event.getModifiedType()),
|
||||||
|
event.getCause(),
|
||||||
|
event.getAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
void handlePotionEffect(
|
||||||
|
Player player,
|
||||||
|
boolean invisibility,
|
||||||
|
EntityPotionEffectEvent.Cause cause,
|
||||||
|
EntityPotionEffectEvent.Action action) {
|
||||||
|
if (!invisibility) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean addedOrChanged = action == EntityPotionEffectEvent.Action.ADDED
|
||||||
|
|| action == EntityPotionEffectEvent.Action.CHANGED;
|
||||||
|
if (cause == EntityPotionEffectEvent.Cause.POTION_DRINK && addedOrChanged) {
|
||||||
|
progression.begin(player.getUniqueId(), player.getName(), Instant.now(clock));
|
||||||
|
} else {
|
||||||
|
progression.stop(player.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Resolves only already-known UUIDs and exact names without creating offline-player records. */
|
||||||
|
public final class KnownPlayerResolver {
|
||||||
|
private final Supplier<PersistentStealthState> state;
|
||||||
|
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
|
||||||
|
|
||||||
|
public KnownPlayerResolver(
|
||||||
|
Supplier<PersistentStealthState> state,
|
||||||
|
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
|
||||||
|
this.state = Objects.requireNonNull(state, "state");
|
||||||
|
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> completeNames(String prefix) {
|
||||||
|
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return knownPlayers().values().stream()
|
||||||
|
.map(KnownPlayer::name)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Resolution resolve(String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return new Resolution(Status.UNKNOWN, null);
|
||||||
|
}
|
||||||
|
Map<UUID, KnownPlayer> known = knownPlayers();
|
||||||
|
try {
|
||||||
|
UUID playerId = UUID.fromString(query);
|
||||||
|
KnownPlayer player = known.get(playerId);
|
||||||
|
return player == null ? new Resolution(Status.UNKNOWN, null) : new Resolution(Status.FOUND, player);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
// Continue with exact case-insensitive name resolution.
|
||||||
|
}
|
||||||
|
String normalized = query.toLowerCase(Locale.ROOT);
|
||||||
|
java.util.List<KnownPlayer> matches = known.values().stream()
|
||||||
|
.filter(player -> player.name() != null && player.name().toLowerCase(Locale.ROOT).equals(normalized))
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty()) {
|
||||||
|
return new Resolution(Status.UNKNOWN, null);
|
||||||
|
}
|
||||||
|
if (matches.size() > 1) {
|
||||||
|
return new Resolution(Status.AMBIGUOUS, null);
|
||||||
|
}
|
||||||
|
return new Resolution(Status.FOUND, matches.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<UUID, KnownPlayer> knownPlayers() {
|
||||||
|
Map<UUID, KnownPlayer> known = new LinkedHashMap<>();
|
||||||
|
state.get().players().forEach((playerId, playerState) ->
|
||||||
|
known.put(playerId, new KnownPlayer(playerId, playerState.lastKnownName(), null)));
|
||||||
|
for (Player player : onlinePlayers.get()) {
|
||||||
|
known.put(player.getUniqueId(), new KnownPlayer(player.getUniqueId(), player.getName(), player));
|
||||||
|
}
|
||||||
|
return known;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Status { FOUND, UNKNOWN, AMBIGUOUS }
|
||||||
|
|
||||||
|
public record KnownPlayer(UUID playerId, String name, Player onlinePlayer) { }
|
||||||
|
|
||||||
|
public record Resolution(Status status, KnownPlayer player) { }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
|
/** Accumulates qualifying potion invisibility using monotonic elapsed time. */
|
||||||
|
public final class QualifyingInvisibilityService {
|
||||||
|
private final StealthStateManager stateManager;
|
||||||
|
private final long unlockThresholdMillis;
|
||||||
|
private final LongSupplier nanoTime;
|
||||||
|
private final Consumer<UUID> unlockNotifier;
|
||||||
|
private final Map<UUID, ActiveInterval> activeIntervals = new HashMap<>();
|
||||||
|
|
||||||
|
public QualifyingInvisibilityService(
|
||||||
|
StealthStateManager stateManager,
|
||||||
|
Duration unlockThreshold,
|
||||||
|
LongSupplier nanoTime,
|
||||||
|
Consumer<UUID> unlockNotifier) {
|
||||||
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
|
this.unlockThresholdMillis = Objects.requireNonNull(unlockThreshold, "unlockThreshold").toMillis();
|
||||||
|
this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime");
|
||||||
|
this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier");
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> begin(UUID playerId, String playerName, Instant startedAt) {
|
||||||
|
Objects.requireNonNull(playerId, "playerId");
|
||||||
|
Objects.requireNonNull(startedAt, "startedAt");
|
||||||
|
long nowNanos = nanoTime.getAsLong();
|
||||||
|
ActiveInterval previous = activeIntervals.put(playerId, new ActiveInterval(nowNanos, startedAt, playerName));
|
||||||
|
return record(playerId, playerName, elapsedMillis(previous, nowNanos), startedAt, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> checkpoint(UUID playerId) {
|
||||||
|
long nowNanos = nanoTime.getAsLong();
|
||||||
|
ActiveInterval previous = activeIntervals.get(playerId);
|
||||||
|
if (previous == null) {
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
long elapsedMillis = elapsedMillis(previous, nowNanos);
|
||||||
|
Instant continuedAt = previous.startedAt().plusMillis(elapsedMillis);
|
||||||
|
activeIntervals.put(playerId, new ActiveInterval(nowNanos, continuedAt, previous.playerName()));
|
||||||
|
return record(playerId, previous.playerName(), elapsedMillis, continuedAt, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> stop(UUID playerId) {
|
||||||
|
long nowNanos = nanoTime.getAsLong();
|
||||||
|
ActiveInterval previous = activeIntervals.remove(playerId);
|
||||||
|
if (previous == null) {
|
||||||
|
return CompletableFuture.completedFuture(null);
|
||||||
|
}
|
||||||
|
return record(playerId, previous.playerName(), elapsedMillis(previous, nowNanos), null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isQualifying(UUID playerId) {
|
||||||
|
return activeIntervals.containsKey(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<UUID> activePlayerIds() {
|
||||||
|
return Set.copyOf(activeIntervals.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> stopAll() {
|
||||||
|
CompletableFuture<?>[] stops = activePlayerIds().stream()
|
||||||
|
.map(this::stop)
|
||||||
|
.toArray(CompletableFuture[]::new);
|
||||||
|
return CompletableFuture.allOf(stops);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long currentAccumulatedMillis(UUID playerId) {
|
||||||
|
long persisted = stateManager.snapshot().player(playerId).accumulatedMillis();
|
||||||
|
return Math.addExact(persisted, elapsedMillis(activeIntervals.get(playerId), nanoTime.getAsLong()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Void> record(
|
||||||
|
UUID playerId,
|
||||||
|
String playerName,
|
||||||
|
long elapsedMillis,
|
||||||
|
Instant qualifyingSince,
|
||||||
|
boolean active) {
|
||||||
|
AtomicBoolean unlockedNow = new AtomicBoolean();
|
||||||
|
CompletableFuture<Void> saved = stateManager.update(state -> {
|
||||||
|
PlayerStealthState player = state.player(playerId);
|
||||||
|
long accumulated = Math.addExact(player.accumulatedMillis(), elapsedMillis);
|
||||||
|
boolean unlocked = player.unlocked() || accumulated >= unlockThresholdMillis;
|
||||||
|
unlockedNow.set(unlocked && !player.unlocked());
|
||||||
|
PlayerStealthState updated = new PlayerStealthState(
|
||||||
|
playerId,
|
||||||
|
playerName,
|
||||||
|
accumulated,
|
||||||
|
unlocked,
|
||||||
|
player.preparedLogin(),
|
||||||
|
player.concealed(),
|
||||||
|
active ? qualifyingSince : null,
|
||||||
|
player.unknownFields());
|
||||||
|
return state.withPlayer(updated);
|
||||||
|
});
|
||||||
|
return saved.thenRun(() -> {
|
||||||
|
if (unlockedNow.get()) {
|
||||||
|
unlockNotifier.accept(playerId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long elapsedMillis(ActiveInterval interval, long nowNanos) {
|
||||||
|
if (interval == null) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
long elapsedNanos = Math.max(0L, nowNanos - interval.startedNanos());
|
||||||
|
return Duration.ofNanos(elapsedNanos).toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ActiveInterval(long startedNanos, Instant startedAt, String playerName) { }
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
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.util.Objects;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.CompletionException;
|
import java.util.concurrent.CompletionException;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
@@ -9,6 +12,9 @@ import org.bukkit.plugin.java.JavaPlugin;
|
|||||||
public final class SpigotStealthPlugin extends JavaPlugin {
|
public final class SpigotStealthPlugin extends JavaPlugin {
|
||||||
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
||||||
private StealthSettings settings;
|
private StealthSettings settings;
|
||||||
|
private QualifyingInvisibilityService progression;
|
||||||
|
private StealthSessionService sessions;
|
||||||
|
private IdentityPresentation identityPresentation;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -35,6 +41,17 @@ 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) {
|
||||||
|
progression.stopAll().join();
|
||||||
|
}
|
||||||
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
|
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
|
||||||
stateManagerFuture.join().close();
|
stateManagerFuture.join().close();
|
||||||
}
|
}
|
||||||
@@ -45,11 +62,60 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void finishInitialization(StealthStateManager manager) {
|
private void finishInitialization(StealthStateManager manager) {
|
||||||
getServer().getScheduler().runTaskTimer(this, ignored -> manager.save().exceptionally(failure -> {
|
java.util.function.Consumer<Runnable> mainThread =
|
||||||
|
runnable -> getServer().getScheduler().runTask(this, runnable);
|
||||||
|
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
|
||||||
|
getServer()::getPlayer,
|
||||||
|
mainThread,
|
||||||
|
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);
|
||||||
|
org.bukkit.command.PluginCommand stealthPluginCommand =
|
||||||
|
Objects.requireNonNull(getCommand("stealth"), "stealth command");
|
||||||
|
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
|
||||||
|
stealthPluginCommand.setExecutor(stealthCommand);
|
||||||
|
stealthPluginCommand.setTabCompleter(stealthCommand);
|
||||||
|
StealthAdministrationService administration = new StealthAdministrationService(
|
||||||
|
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
|
||||||
|
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
|
||||||
|
org.bukkit.command.PluginCommand stealthAdminPluginCommand =
|
||||||
|
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command");
|
||||||
|
StealthAdminCommand stealthAdminCommand = new StealthAdminCommand(
|
||||||
|
administration,
|
||||||
|
resolver,
|
||||||
|
mainThread,
|
||||||
|
getLogger()::info,
|
||||||
|
settings.unlockThreshold());
|
||||||
|
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
|
||||||
|
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
|
||||||
|
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||||
|
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logSaveFailure(CompletableFuture<Void> save) {
|
||||||
|
save.exceptionally(failure -> {
|
||||||
getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure));
|
getLogger().severe("Unable to save Spigot Stealth state: " + rootMessage(failure));
|
||||||
return null;
|
return null;
|
||||||
}), 6000L, 6000L);
|
});
|
||||||
getLogger().info("Spigot Stealth enabled");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String rootMessage(Throwable throwable) {
|
private static String rootMessage(Throwable throwable) {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
|
||||||
|
/** Permission-gated administrative command for online and known offline players. */
|
||||||
|
public final class StealthAdminCommand implements CommandExecutor, TabCompleter {
|
||||||
|
private static final String PERMISSION = "spigotstealth.admin";
|
||||||
|
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list");
|
||||||
|
private final StealthAdministrationService administration;
|
||||||
|
private final KnownPlayerResolver resolver;
|
||||||
|
private final Consumer<Runnable> mainThread;
|
||||||
|
private final Consumer<String> auditLog;
|
||||||
|
private final Duration unlockThreshold;
|
||||||
|
|
||||||
|
public StealthAdminCommand(
|
||||||
|
StealthAdministrationService administration,
|
||||||
|
KnownPlayerResolver resolver,
|
||||||
|
Consumer<Runnable> mainThread,
|
||||||
|
Consumer<String> auditLog,
|
||||||
|
Duration unlockThreshold) {
|
||||||
|
this.administration = Objects.requireNonNull(administration, "administration");
|
||||||
|
this.resolver = Objects.requireNonNull(resolver, "resolver");
|
||||||
|
this.mainThread = Objects.requireNonNull(mainThread, "mainThread");
|
||||||
|
this.auditLog = Objects.requireNonNull(auditLog, "auditLog");
|
||||||
|
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (!sender.hasPermission(PERMISSION)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
return matching(OPERATIONS, arguments[0]);
|
||||||
|
}
|
||||||
|
String operation = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
if (arguments.length == 2) {
|
||||||
|
if (List.of("status", "grant", "reset").contains(operation)) {
|
||||||
|
return resolver.completeNames(arguments[1]);
|
||||||
|
}
|
||||||
|
if ("list".equals(operation)) {
|
||||||
|
return matching(List.of("unlocked"), arguments[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (arguments.length == 3 && "reset".equals(operation)) {
|
||||||
|
return matching(List.of("confirm"), arguments[2]);
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> matching(List<String> candidates, String prefix) {
|
||||||
|
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||||
|
return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (!sender.hasPermission(PERMISSION)) {
|
||||||
|
sender.sendMessage("You do not have permission to administer Spigot Stealth.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) {
|
||||||
|
if (arguments.length == 1) {
|
||||||
|
List<String> names = administration.concealedOnlineNames();
|
||||||
|
sender.sendMessage(names.isEmpty()
|
||||||
|
? "No players are currently concealed."
|
||||||
|
: "Currently concealed: " + String.join(", ", names));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) {
|
||||||
|
List<String> names = administration.unlockedPlayerNames();
|
||||||
|
sender.sendMessage(names.isEmpty()
|
||||||
|
? "No players have unlocked stealth."
|
||||||
|
: "Stealth unlocked: " + String.join(", ", names));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sendUsage(sender, label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length < 2) {
|
||||||
|
sendUsage(sender, label);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String operation = arguments[0].toLowerCase(Locale.ROOT);
|
||||||
|
KnownPlayerResolver.Resolution resolution = resolver.resolve(arguments[1]);
|
||||||
|
if (resolution.status() != KnownPlayerResolver.Status.FOUND) {
|
||||||
|
sender.sendMessage(resolution.status() == KnownPlayerResolver.Status.AMBIGUOUS
|
||||||
|
? "That player name is ambiguous; use a UUID."
|
||||||
|
: "Unknown player. Use an exact known name or UUID.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
KnownPlayerResolver.KnownPlayer target = resolution.player();
|
||||||
|
return switch (operation) {
|
||||||
|
case "status" -> status(sender, target);
|
||||||
|
case "grant" -> grant(sender, target);
|
||||||
|
case "reset" -> reset(sender, label, arguments, target);
|
||||||
|
default -> {
|
||||||
|
sendUsage(sender, label);
|
||||||
|
yield true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean status(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
|
||||||
|
StealthAdministrationService.PlayerStatus status = administration.status(target.playerId());
|
||||||
|
sender.sendMessage("Stealth status for " + displayName(target) + " (" + target.playerId() + "):");
|
||||||
|
sender.sendMessage("progress=" + StealthProgressFormatter.duration(status.accumulatedMillis())
|
||||||
|
+ "/" + StealthProgressFormatter.duration(unlockThreshold.toMillis())
|
||||||
|
+ " unlocked=" + yesNo(status.unlocked())
|
||||||
|
+ " prepared=" + yesNo(status.preparedLogin())
|
||||||
|
+ " concealed=" + yesNo(status.concealed())
|
||||||
|
+ " qualifying=" + yesNo(status.qualifyingNow()));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean grant(CommandSender sender, KnownPlayerResolver.KnownPlayer target) {
|
||||||
|
CompletableFuture<StealthAdministrationService.ChangeResult> grant =
|
||||||
|
administration.grant(target.playerId(), target.name());
|
||||||
|
grant.whenComplete((result, failure) -> mainThread.accept(() -> {
|
||||||
|
if (failure != null) {
|
||||||
|
sender.sendMessage("Unable to persist the stealth grant; check the server log.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sender.sendMessage(result.changed()
|
||||||
|
? "Stealth unlock granted to " + displayName(target) + "."
|
||||||
|
: displayName(target) + " already has the stealth unlock.");
|
||||||
|
auditLog.accept("stealthadmin grant administrator=" + sender.getName()
|
||||||
|
+ " target=" + target.playerId() + " changed=" + result.changed());
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean reset(
|
||||||
|
CommandSender sender,
|
||||||
|
String label,
|
||||||
|
String[] arguments,
|
||||||
|
KnownPlayerResolver.KnownPlayer target) {
|
||||||
|
if (arguments.length != 3 || !"confirm".equalsIgnoreCase(arguments[2])) {
|
||||||
|
sender.sendMessage("Confirm the complete reset with /" + label + " reset " + arguments[1] + " confirm");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
administration.reset(target.playerId(), target.name(), target.onlinePlayer())
|
||||||
|
.whenComplete((ignored, failure) -> mainThread.accept(() -> {
|
||||||
|
if (failure != null) {
|
||||||
|
sender.sendMessage("Unable to persist the stealth reset; check the server log.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sender.sendMessage("Stealth reset complete for " + displayName(target) + ".");
|
||||||
|
auditLog.accept("stealthadmin reset administrator=" + sender.getName()
|
||||||
|
+ " target=" + target.playerId());
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sendUsage(CommandSender sender, String label) {
|
||||||
|
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String displayName(KnownPlayerResolver.KnownPlayer player) {
|
||||||
|
return player.name() == null ? player.playerId().toString() : player.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String yesNo(boolean value) {
|
||||||
|
return value ? "yes" : "no";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Durable administrative operations over player stealth state. */
|
||||||
|
public final class StealthAdministrationService {
|
||||||
|
private final StealthStateManager stateManager;
|
||||||
|
private final QualifyingInvisibilityService progression;
|
||||||
|
private final IdentityPresentation presentation;
|
||||||
|
private final Consumer<UUID> unlockNotifier;
|
||||||
|
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
|
||||||
|
|
||||||
|
public StealthAdministrationService(
|
||||||
|
StealthStateManager stateManager,
|
||||||
|
QualifyingInvisibilityService progression,
|
||||||
|
IdentityPresentation presentation,
|
||||||
|
Consumer<UUID> unlockNotifier,
|
||||||
|
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
|
||||||
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
|
this.progression = Objects.requireNonNull(progression, "progression");
|
||||||
|
this.presentation = Objects.requireNonNull(presentation, "presentation");
|
||||||
|
this.unlockNotifier = Objects.requireNonNull(unlockNotifier, "unlockNotifier");
|
||||||
|
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerStatus status(UUID playerId) {
|
||||||
|
PlayerStealthState player = stateManager.snapshot().player(playerId);
|
||||||
|
return new PlayerStatus(
|
||||||
|
progression.currentAccumulatedMillis(playerId),
|
||||||
|
player.unlocked(),
|
||||||
|
player.preparedLogin(),
|
||||||
|
player.concealed(),
|
||||||
|
progression.isQualifying(playerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<ChangeResult> grant(UUID playerId, String playerName) {
|
||||||
|
AtomicBoolean changed = new AtomicBoolean();
|
||||||
|
CompletableFuture<Void> saved = stateManager.update(state -> {
|
||||||
|
PlayerStealthState player = state.player(playerId).withLastKnownName(playerName);
|
||||||
|
if (player.unlocked()) {
|
||||||
|
return state.withPlayer(player);
|
||||||
|
}
|
||||||
|
changed.set(true);
|
||||||
|
return state.withPlayer(player.withProgress(player.accumulatedMillis(), true));
|
||||||
|
});
|
||||||
|
return saved.thenApply(ignored -> {
|
||||||
|
if (changed.get()) {
|
||||||
|
unlockNotifier.accept(playerId);
|
||||||
|
}
|
||||||
|
return new ChangeResult(changed.get());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> reset(UUID playerId, String playerName, Player onlinePlayer) {
|
||||||
|
CompletableFuture<Void> stopped = progression.stop(playerId);
|
||||||
|
CompletableFuture<Void> reset = stateManager.update(state -> {
|
||||||
|
PlayerStealthState player = state.player(playerId);
|
||||||
|
PlayerStealthState cleared = new PlayerStealthState(
|
||||||
|
playerId, playerName, 0L, false, false, false, null, player.unknownFields());
|
||||||
|
return state.withPlayer(cleared);
|
||||||
|
});
|
||||||
|
if (onlinePlayer != null) {
|
||||||
|
presentation.reveal(onlinePlayer);
|
||||||
|
}
|
||||||
|
return CompletableFuture.allOf(stopped, reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> unlockedPlayerNames() {
|
||||||
|
return stateManager.snapshot().players().entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue().unlocked())
|
||||||
|
.map(entry -> {
|
||||||
|
String name = entry.getValue().lastKnownName();
|
||||||
|
return name == null || name.isBlank() ? entry.getKey().toString() : name;
|
||||||
|
})
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> concealedOnlineNames() {
|
||||||
|
return onlinePlayers.get().stream()
|
||||||
|
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
|
||||||
|
.map(Player::getName)
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PlayerStatus(
|
||||||
|
long accumulatedMillis,
|
||||||
|
boolean unlocked,
|
||||||
|
boolean preparedLogin,
|
||||||
|
boolean concealed,
|
||||||
|
boolean qualifyingNow) { }
|
||||||
|
|
||||||
|
public record ChangeResult(boolean changed) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandExecutor;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabCompleter;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Player-facing progress command. */
|
||||||
|
public final class StealthCommand implements CommandExecutor, TabCompleter {
|
||||||
|
private final StealthStateManager stateManager;
|
||||||
|
private final QualifyingInvisibilityService progression;
|
||||||
|
private final StealthSettings settings;
|
||||||
|
|
||||||
|
public StealthCommand(
|
||||||
|
StealthStateManager stateManager,
|
||||||
|
QualifyingInvisibilityService progression,
|
||||||
|
StealthSettings settings) {
|
||||||
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
|
this.progression = Objects.requireNonNull(progression, "progression");
|
||||||
|
this.settings = Objects.requireNonNull(settings, "settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (arguments.length == 1 && "progress".startsWith(arguments[0].toLowerCase(Locale.ROOT))) {
|
||||||
|
return List.of("progress");
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||||
|
if (!(sender instanceof Player player)) {
|
||||||
|
sender.sendMessage("Only players can check personal stealth progress.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (arguments.length != 1 || !"progress".equals(arguments[0].toLowerCase(Locale.ROOT))) {
|
||||||
|
sender.sendMessage("Usage: /" + label + " progress");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
PlayerStealthState state = stateManager.snapshot().player(player.getUniqueId());
|
||||||
|
long accumulated = progression.currentAccumulatedMillis(player.getUniqueId());
|
||||||
|
if (state.unlocked()) {
|
||||||
|
player.sendMessage("Stealth is unlocked.");
|
||||||
|
player.sendMessage("Drink an invisibility potion and log out while invisible to conceal your next login.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
long target = settings.unlockThreshold().toMillis();
|
||||||
|
long remaining = Math.max(0L, target - accumulated);
|
||||||
|
String message = settings.progressMessage()
|
||||||
|
.replace("{progress}", StealthProgressFormatter.duration(accumulated))
|
||||||
|
.replace("{target}", StealthProgressFormatter.duration(target))
|
||||||
|
.replace("{remaining}", StealthProgressFormatter.duration(remaining));
|
||||||
|
player.sendMessage(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
/** Human-readable duration and progress presentation. */
|
||||||
|
public final class StealthProgressFormatter {
|
||||||
|
private StealthProgressFormatter() { }
|
||||||
|
|
||||||
|
public static String duration(long milliseconds) {
|
||||||
|
long totalSeconds = Math.max(0L, milliseconds) / 1000L;
|
||||||
|
long hours = totalSeconds / 3600L;
|
||||||
|
long minutes = (totalSeconds % 3600L) / 60L;
|
||||||
|
long seconds = totalSeconds % 60L;
|
||||||
|
StringBuilder formatted = new StringBuilder();
|
||||||
|
if (hours > 0L) {
|
||||||
|
formatted.append(hours).append('h');
|
||||||
|
}
|
||||||
|
if (minutes > 0L) {
|
||||||
|
appendSpace(formatted);
|
||||||
|
formatted.append(minutes).append('m');
|
||||||
|
}
|
||||||
|
if (seconds > 0L || formatted.length() == 0) {
|
||||||
|
appendSpace(formatted);
|
||||||
|
formatted.append(seconds).append('s');
|
||||||
|
}
|
||||||
|
return formatted.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendSpace(StringBuilder value) {
|
||||||
|
if (value.length() > 0) {
|
||||||
|
value.append(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -11,7 +12,7 @@ commands:
|
|||||||
permission: spigotstealth.use
|
permission: spigotstealth.use
|
||||||
stealthadmin:
|
stealthadmin:
|
||||||
description: Inspect and administer Spigot Stealth.
|
description: Inspect and administer Spigot Stealth.
|
||||||
usage: /stealthadmin <status|grant|reset|list>
|
usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>
|
||||||
permission: spigotstealth.admin
|
permission: spigotstealth.admin
|
||||||
permissions:
|
permissions:
|
||||||
spigotstealth.use:
|
spigotstealth.use:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class BukkitUnlockNotifierTest {
|
||||||
|
@Test
|
||||||
|
void sendsFullScreenTitleAndChatMessageToOnlinePlayer() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.isOnline()).thenReturn(true);
|
||||||
|
Consumer<Runnable> immediateMainThread = Runnable::run;
|
||||||
|
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
|
||||||
|
ignored -> player, immediateMainThread, "Stealth unlocked!");
|
||||||
|
|
||||||
|
notifier.accept(playerId);
|
||||||
|
|
||||||
|
verify(player).sendTitle("Stealth Unlocked", "Your identity can now be concealed", 10, 100, 20);
|
||||||
|
verify(player).sendMessage("Stealth unlocked!");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.entity.EntityPotionEffectEvent;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class InvisibilityEffectListenerTest {
|
||||||
|
@Test
|
||||||
|
void onlyPotionDrinkCauseStartsQualifyingInvisibility() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
AtomicLong nanos = new AtomicLong();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), nanos::get, ignored -> { });
|
||||||
|
InvisibilityEffectListener listener = new InvisibilityEffectListener(
|
||||||
|
service, Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
|
||||||
|
|
||||||
|
Player player = player(playerId);
|
||||||
|
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_SPLASH, EntityPotionEffectEvent.Action.ADDED);
|
||||||
|
assertFalse(service.isQualifying(playerId));
|
||||||
|
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.COMMAND, EntityPotionEffectEvent.Action.CHANGED);
|
||||||
|
assertFalse(service.isQualifying(playerId));
|
||||||
|
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED);
|
||||||
|
assertTrue(service.isQualifying(playerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyLaterNonDrinkChangeStopsTheQualifyingInterval() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
InvisibilityEffectListener listener = new InvisibilityEffectListener(service, Clock.systemUTC());
|
||||||
|
Player player = player(playerId);
|
||||||
|
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.POTION_DRINK, EntityPotionEffectEvent.Action.ADDED);
|
||||||
|
listener.handlePotionEffect(player, true, EntityPotionEffectEvent.Cause.PLUGIN, EntityPotionEffectEvent.Action.CHANGED);
|
||||||
|
assertFalse(service.isQualifying(playerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,33 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class KnownPlayerResolverTest {
|
||||||
|
@Test
|
||||||
|
void resolvesKnownOfflinePlayerByExactNameOrUuid() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
PlayerStealthState player = PlayerStealthState.empty(playerId).withLastKnownName("Alex");
|
||||||
|
PersistentStealthState state = new PersistentStealthState(Map.of(playerId, player), Map.of());
|
||||||
|
KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of);
|
||||||
|
assertEquals(playerId, resolver.resolve("alex").player().playerId());
|
||||||
|
assertEquals(playerId, resolver.resolve(playerId.toString()).player().playerId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsAmbiguousAndUnknownNamesWithoutCreatingRecords() {
|
||||||
|
UUID first = UUID.randomUUID();
|
||||||
|
UUID second = UUID.randomUUID();
|
||||||
|
PersistentStealthState state = new PersistentStealthState(Map.of(
|
||||||
|
first, PlayerStealthState.empty(first).withLastKnownName("Alex"),
|
||||||
|
second, PlayerStealthState.empty(second).withLastKnownName("ALEX")), Map.of());
|
||||||
|
KnownPlayerResolver resolver = new KnownPlayerResolver(() -> state, List::of);
|
||||||
|
assertEquals(KnownPlayerResolver.Status.AMBIGUOUS, resolver.resolve("alex").status());
|
||||||
|
assertEquals(KnownPlayerResolver.Status.UNKNOWN, resolver.resolve("nobody").status());
|
||||||
|
assertEquals(2, state.players().size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,80 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
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.ArrayList;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class QualifyingInvisibilityServiceTest {
|
||||||
|
@Test
|
||||||
|
void accumulatesMonotonicElapsedTimeAcrossQualifyingIntervals() {
|
||||||
|
AtomicLong nanos = new AtomicLong();
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), nanos::get, ignored -> { });
|
||||||
|
|
||||||
|
service.begin(playerId, "Alex", Instant.parse("2026-08-14T10:00:00Z"));
|
||||||
|
nanos.addAndGet(Duration.ofMinutes(3).toNanos());
|
||||||
|
service.stop(playerId).join();
|
||||||
|
service.begin(playerId, "Alex", Instant.parse("2026-08-14T11:00:00Z"));
|
||||||
|
nanos.addAndGet(Duration.ofMinutes(2).toNanos());
|
||||||
|
service.stop(playerId).join();
|
||||||
|
|
||||||
|
assertEquals(Duration.ofMinutes(5).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
|
||||||
|
assertFalse(service.isQualifying(playerId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refreshSettlesExistingIntervalWithoutDoubleCounting() {
|
||||||
|
AtomicLong nanos = new AtomicLong();
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), nanos::get, ignored -> { });
|
||||||
|
service.begin(playerId, "Alex", Instant.EPOCH);
|
||||||
|
nanos.addAndGet(Duration.ofSeconds(5).toNanos());
|
||||||
|
service.begin(playerId, "Alex", Instant.EPOCH.plusSeconds(5)).join();
|
||||||
|
nanos.addAndGet(Duration.ofSeconds(5).toNanos());
|
||||||
|
service.stop(playerId).join();
|
||||||
|
assertEquals(Duration.ofSeconds(10).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlocksAndNotifiesExactlyOnceWhilePreservingExcessTime() {
|
||||||
|
AtomicLong nanos = new AtomicLong();
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
ArrayList<UUID> notifications = new ArrayList<>();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService service = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofSeconds(10), nanos::get, notifications::add);
|
||||||
|
service.begin(playerId, "Alex", Instant.EPOCH);
|
||||||
|
nanos.addAndGet(Duration.ofSeconds(11).toNanos());
|
||||||
|
service.checkpoint(playerId).join();
|
||||||
|
nanos.addAndGet(Duration.ofSeconds(2).toNanos());
|
||||||
|
service.stop(playerId).join();
|
||||||
|
|
||||||
|
PlayerStealthState player = manager.snapshot().player(playerId);
|
||||||
|
assertTrue(player.unlocked());
|
||||||
|
assertEquals(Duration.ofSeconds(13).toMillis(), player.accumulatedMillis());
|
||||||
|
assertEquals(java.util.List.of(playerId), notifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,154 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
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.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StealthAdminCommandTest {
|
||||||
|
@Test
|
||||||
|
void completesAdministrativeOperationsByPrefix() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
assertEquals(
|
||||||
|
List.of("grant"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("status", "grant", "reset", "list"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(true), mock(Command.class), "stealthadmin", new String[] {""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completesContextualArgumentsAndSuppressesIrrelevantSuggestions() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender administrator = sender(true);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"status", "al"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("confirm"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"reset", "Alex", "co"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of("unlocked"),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"list", "un"}));
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
administrator, command, "stealthadmin", new String[] {"status", "Alex", ""}));
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
fixture.command.onTabComplete(
|
||||||
|
sender(false), command, "stealthadmin", new String[] {""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statusInspectsKnownOfflinePlayerAndReportsAllState() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
CommandSender sender = sender(true);
|
||||||
|
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"status", "Alex"});
|
||||||
|
verify(sender).sendMessage(contains(playerId.toString()));
|
||||||
|
verify(sender).sendMessage(contains("prepared=yes"));
|
||||||
|
verify(sender).sendMessage(contains("concealed=no"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsUnlockedPlayersAndClearlyReportsWhenThereAreNone() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
CommandSender emptySender = sender(true);
|
||||||
|
fixture.command.onCommand(
|
||||||
|
emptySender, command, "stealthadmin", new String[] {"list", "unlocked"});
|
||||||
|
verify(emptySender).sendMessage("No players have unlocked stealth.");
|
||||||
|
|
||||||
|
fixture.manager.update(state -> state.withPlayer(
|
||||||
|
state.player(playerId).withProgress(5000L, true))).join();
|
||||||
|
CommandSender populatedSender = sender(true);
|
||||||
|
fixture.command.onCommand(
|
||||||
|
populatedSender, command, "stealthadmin", new String[] {"list", "unlocked"});
|
||||||
|
verify(populatedSender).sendMessage("Stealth unlocked: Alex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grantPersistsThenReportsAndAuditsAction() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
CommandSender sender = sender(true);
|
||||||
|
fixture.command.onCommand(sender, mock(Command.class), "stealthadmin", new String[] {"grant", playerId.toString()});
|
||||||
|
verify(sender, org.mockito.Mockito.timeout(1000)).sendMessage(contains("granted"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(fixture.manager.snapshot().player(playerId).unlocked());
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(fixture.audit.stream().anyMatch(message -> message.contains("grant") && message.contains(playerId.toString())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetRequiresConfirmationAndPermission() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (Fixture fixture = fixture(playerId)) {
|
||||||
|
CommandSender denied = sender(false);
|
||||||
|
fixture.command.onCommand(denied, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex", "confirm"});
|
||||||
|
verify(denied).sendMessage(contains("permission"));
|
||||||
|
CommandSender allowed = sender(true);
|
||||||
|
fixture.command.onCommand(allowed, mock(Command.class), "stealthadmin", new String[] {"reset", "Alex"});
|
||||||
|
verify(allowed).sendMessage(contains("confirm"));
|
||||||
|
verify(allowed, never()).sendMessage(contains("reset complete"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CommandSender sender(boolean permission) {
|
||||||
|
CommandSender sender = mock(CommandSender.class);
|
||||||
|
when(sender.hasPermission("spigotstealth.admin")).thenReturn(permission);
|
||||||
|
when(sender.getName()).thenReturn("Operator");
|
||||||
|
return sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Fixture fixture(UUID playerId) {
|
||||||
|
StealthStateRepository repository = new StealthStateRepository() {
|
||||||
|
@Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); }
|
||||||
|
@Override public void save(PersistentStealthState state) { }
|
||||||
|
};
|
||||||
|
StealthStateManager manager = new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of()));
|
||||||
|
PlayerStealthState player = new PlayerStealthState(playerId, "Alex", 5000L, false, true, false, null, Map.of());
|
||||||
|
manager.update(state -> state.withPlayer(player)).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthAdministrationService administration = new StealthAdministrationService(
|
||||||
|
manager, progression, mock(IdentityPresentation.class), ignored -> { }, List::of);
|
||||||
|
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, List::of);
|
||||||
|
ArrayList<String> audit = new ArrayList<>();
|
||||||
|
StealthAdminCommand command = new StealthAdminCommand(
|
||||||
|
administration, resolver, Runnable::run, audit::add, Duration.ofHours(8));
|
||||||
|
return new Fixture(manager, command, audit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Fixture(
|
||||||
|
StealthStateManager manager,
|
||||||
|
StealthAdminCommand command,
|
||||||
|
ArrayList<String> audit) implements AutoCloseable {
|
||||||
|
@Override public void close() { manager.close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StealthAdministrationServiceTest {
|
||||||
|
@Test
|
||||||
|
void grantsOfflineUnlockAndNotifiesExactlyOnce() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
ArrayList<UUID> notifications = new ArrayList<>();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
StealthAdministrationService administration = service(manager, List::of, notifications::add);
|
||||||
|
assertTrue(administration.grant(playerId, "Alex").join().changed());
|
||||||
|
assertFalse(administration.grant(playerId, "Alex").join().changed());
|
||||||
|
assertTrue(manager.snapshot().player(playerId).unlocked());
|
||||||
|
assertEquals(List.of(playerId), notifications);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetClearsProgressUnlockPreparationConcealmentAndActiveTracking() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
PlayerStealthState state = new PlayerStealthState(
|
||||||
|
playerId, "Alex", 1234L, true, true, true, Instant.EPOCH, Map.of("future", "keep"));
|
||||||
|
manager.update(all -> all.withPlayer(state)).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
progression.begin(playerId, "Alex", Instant.EPOCH).join();
|
||||||
|
IdentityPresentation presentation = mock(IdentityPresentation.class);
|
||||||
|
StealthAdministrationService administration = new StealthAdministrationService(
|
||||||
|
manager, progression, presentation, ignored -> { }, () -> List.of(player));
|
||||||
|
|
||||||
|
administration.reset(playerId, "Alex", player).join();
|
||||||
|
|
||||||
|
PlayerStealthState reset = manager.snapshot().player(playerId);
|
||||||
|
assertEquals(0L, reset.accumulatedMillis());
|
||||||
|
assertFalse(reset.unlocked());
|
||||||
|
assertFalse(reset.preparedLogin());
|
||||||
|
assertFalse(reset.concealed());
|
||||||
|
assertFalse(progression.isQualifying(playerId));
|
||||||
|
assertEquals("keep", reset.unknownFields().get("future"));
|
||||||
|
verify(presentation).reveal(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsAllUnlockedPlayersByNameOrUuidInCaseInsensitiveOrder() {
|
||||||
|
UUID namelessId = UUID.randomUUID();
|
||||||
|
UUID lockedId = UUID.randomUUID();
|
||||||
|
UUID zoeId = UUID.randomUUID();
|
||||||
|
UUID alexId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state
|
||||||
|
.withPlayer(PlayerStealthState.empty(namelessId).withProgress(0L, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(lockedId).withLastKnownName("Locked"))
|
||||||
|
.withPlayer(PlayerStealthState.empty(zoeId).withLastKnownName("zoe").withProgress(1L, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(alexId).withLastKnownName("Alex").withProgress(2L, true)))
|
||||||
|
.join();
|
||||||
|
StealthAdministrationService administration = service(manager, List::of, ignored -> { });
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("Alex", namelessId.toString(), "zoe").stream()
|
||||||
|
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||||
|
.toList(),
|
||||||
|
administration.unlockedPlayerNames());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsOnlyCurrentlyOnlineConcealedPlayers() {
|
||||||
|
UUID concealedId = UUID.randomUUID();
|
||||||
|
UUID ordinaryId = UUID.randomUUID();
|
||||||
|
Player concealed = player(concealedId, "Hidden");
|
||||||
|
Player ordinary = player(ordinaryId, "Visible");
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state
|
||||||
|
.withPlayer(PlayerStealthState.empty(concealedId).withLastKnownName("Hidden").withSession(false, true))
|
||||||
|
.withPlayer(PlayerStealthState.empty(ordinaryId).withLastKnownName("Visible"))).join();
|
||||||
|
StealthAdministrationService administration = service(
|
||||||
|
manager, () -> List.of(concealed, ordinary), ignored -> { });
|
||||||
|
assertEquals(List.of("Hidden"), administration.concealedOnlineNames());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StealthAdministrationService service(
|
||||||
|
StealthStateManager manager,
|
||||||
|
java.util.function.Supplier<? extends java.util.Collection<? extends Player>> online,
|
||||||
|
java.util.function.Consumer<UUID> notifier) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
return new StealthAdministrationService(
|
||||||
|
manager, progression, mock(IdentityPresentation.class), notifier, online);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID playerId, String name) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
org.mockito.Mockito.when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
org.mockito.Mockito.when(player.getName()).thenReturn(name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,88 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StealthCommandTest {
|
||||||
|
@Test
|
||||||
|
void completesProgressByPrefixWithoutSuggestingPlayersElsewhere() {
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
|
||||||
|
Command bukkitCommand = mock(Command.class);
|
||||||
|
Player player = player(UUID.randomUUID());
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
List.of("progress"),
|
||||||
|
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"pr"}));
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(
|
||||||
|
List.of(),
|
||||||
|
command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"progress", ""}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
AtomicLong nanos = new AtomicLong();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
manager.update(state -> state.withPlayer(state.player(playerId).withAccumulatedMillis(Duration.ofHours(2).toMillis()))).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), nanos::get, ignored -> { });
|
||||||
|
progression.begin(playerId, "Alex", Instant.EPOCH).join();
|
||||||
|
nanos.addAndGet(Duration.ofMinutes(5).toNanos());
|
||||||
|
Player player = player(playerId);
|
||||||
|
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
|
||||||
|
|
||||||
|
assertTrue(command.onCommand(player, mock(Command.class), "stealth", new String[] {"progress"}));
|
||||||
|
verify(player).sendMessage(contains("2h 5m / 8h"));
|
||||||
|
verify(player).sendMessage(contains("5h 55m remaining"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals(Duration.ofHours(2).toMillis(), manager.snapshot().player(playerId).accumulatedMillis());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unlockedPlayerReceivesUsageGuidance() {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
try (StealthStateManager manager = manager()) {
|
||||||
|
PlayerStealthState unlocked = new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of());
|
||||||
|
manager.update(state -> state.withPlayer(unlocked)).join();
|
||||||
|
QualifyingInvisibilityService progression = new QualifyingInvisibilityService(
|
||||||
|
manager, Duration.ofHours(8), System::nanoTime, ignored -> { });
|
||||||
|
Player player = player(playerId);
|
||||||
|
StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of()));
|
||||||
|
|
||||||
|
command.onCommand(player, mock(Command.class), "stealth", new String[] {"progress"});
|
||||||
|
verify(player).sendMessage(contains("unlocked"));
|
||||||
|
verify(player).sendMessage(contains("log out while invisible"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID playerId) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(playerId);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,15 @@
|
|||||||
|
package games.dmg.spigotstealth;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StealthProgressFormatterTest {
|
||||||
|
@Test
|
||||||
|
void presentsConciseHumanReadableDurations() {
|
||||||
|
assertEquals("2h 5m 9s", StealthProgressFormatter.duration(Duration.ofHours(2).plusMinutes(5).plusSeconds(9).toMillis()));
|
||||||
|
assertEquals("45s", StealthProgressFormatter.duration(45_900L));
|
||||||
|
assertEquals("0s", StealthProgressFormatter.duration(0L));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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