feat(commands): show player stealth progress
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## 2026-08-14
|
## 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -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,17 @@ 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.
|
||||||
|
|
||||||
|
## 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
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package games.dmg.spigotstealth;
|
|||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.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;
|
||||||
@@ -58,6 +59,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
|
|||||||
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
manager, settings.unlockThreshold(), System::nanoTime, notifier);
|
||||||
getServer().getPluginManager().registerEvents(
|
getServer().getPluginManager().registerEvents(
|
||||||
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
|
||||||
|
Objects.requireNonNull(getCommand("stealth"), "stealth command")
|
||||||
|
.setExecutor(new StealthCommand(manager, progression, settings));
|
||||||
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
getServer().getScheduler().runTaskTimer(this, ignored -> {
|
||||||
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
for (java.util.UUID playerId : progression.activePlayerIds()) {
|
||||||
logSaveFailure(progression.checkpoint(playerId));
|
logSaveFailure(progression.checkpoint(playerId));
|
||||||
|
|||||||
@@ -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,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