Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90851a0d87 | ||
|
|
91c9e7b9ad |
@@ -2,6 +2,10 @@
|
||||
|
||||
## 2026-08-14
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-001: Accumulate invisibility time and unlock stealth"
|
||||
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
|
||||
@@ -11,15 +11,19 @@ As a **player**, I want my qualifying invisibility time to accumulate so that su
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Qualifying time accumulates across effects, sessions, and server restarts.
|
||||
- [ ] 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.
|
||||
- [ ] 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] Only time spent online under an invisibility effect produced by a potion the player directly drank contributes to progression.
|
||||
- [x] Invisibility received from splash potions, lingering potions, tipped arrows, commands, plugins, or other sources does not contribute.
|
||||
- [x] Qualifying time stops when the effect ends, is removed, is replaced by a non-qualifying source, the player disconnects, or the plugin disables.
|
||||
- [x] Refreshed or overlapping qualifying effects never count elapsed time more than once.
|
||||
- [x] Qualifying time accumulates across effects, sessions, and server restarts.
|
||||
- [x] The ability unlocks when accumulated qualifying time reaches eight hours by default.
|
||||
- [x] Reaching the threshold grants the unlock exactly once without discarding excess elapsed time.
|
||||
- [x] When an online player unlocks stealth, they receive both a full-screen title and a chat message explaining the ability.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-003: Check personal stealth progress"
|
||||
description: Show a player their accumulated qualifying invisibility time and stealth unlock status.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Check personal stealth progress
|
||||
@@ -11,13 +11,17 @@ As a **player**, I want to check my stealth progress so that I know how close I
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/stealth progress` reports the player's accumulated qualifying invisibility time.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Repeated command use does not change progression or concealment state.
|
||||
- [ ] The command has clear usage metadata and an appropriate player permission.
|
||||
- [x] `/stealth progress` reports the player's accumulated qualifying invisibility time.
|
||||
- [x] Before unlock, the command reports the configured target and remaining duration.
|
||||
- [x] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval.
|
||||
- [x] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login.
|
||||
- [x] Durations are presented in a concise, human-readable form.
|
||||
- [x] Repeated command use does not change progression or concealment state.
|
||||
- [x] The command has clear usage metadata and an appropriate player permission.
|
||||
|
||||
## Validation
|
||||
|
||||
Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, and unlocked usage guidance. Command metadata and the complete `./gradlew clean check jar` lifecycle pass.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -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,57 @@
|
||||
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.event.player.PlayerQuitEvent;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
progression.stop(event.getPlayer().getUniqueId());
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
@@ -9,6 +11,7 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||
public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
private CompletableFuture<StealthStateManager> stateManagerFuture;
|
||||
private StealthSettings settings;
|
||||
private QualifyingInvisibilityService progression;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -35,6 +38,9 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (progression != null) {
|
||||
progression.stopAll().join();
|
||||
}
|
||||
if (stateManagerFuture != null && stateManagerFuture.isDone() && !stateManagerFuture.isCompletedExceptionally()) {
|
||||
stateManagerFuture.join().close();
|
||||
}
|
||||
@@ -45,11 +51,30 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
||||
}
|
||||
|
||||
private void finishInitialization(StealthStateManager manager) {
|
||||
getServer().getScheduler().runTaskTimer(this, ignored -> manager.save().exceptionally(failure -> {
|
||||
BukkitUnlockNotifier notifier = new BukkitUnlockNotifier(
|
||||
getServer()::getPlayer,
|
||||
runnable -> getServer().getScheduler().runTask(this, runnable),
|
||||
settings.unlockedMessage());
|
||||
progression = new QualifyingInvisibilityService(
|
||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
||||
getServer().getPluginManager().registerEvents(
|
||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
||||
.setExecutor(new StealthCommand(manager, progression, settings));
|
||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
||||
logSaveFailure(progression.checkpoint(playerId));
|
||||
}
|
||||
}, 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));
|
||||
return null;
|
||||
}), 6000L, 6000L);
|
||||
getLogger().info("Spigot Stealth enabled");
|
||||
});
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable throwable) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package games.dmg.spigotstealth;
|
||||
|
||||
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.entity.Player;
|
||||
|
||||
/** Player-facing progress command. */
|
||||
public final class StealthCommand implements CommandExecutor {
|
||||
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 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,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,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,69 @@
|
||||
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.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 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user