diff --git a/design/log.md b/design/log.md index 6cdcb0e..4215634 100644 --- a/design/log.md +++ b/design/log.md @@ -18,3 +18,4 @@ description: Chronological record of material changes to the Spigot Creeper Fear - Completed US-003 with temporary configurable boss bars, current-tier progress presentation, full-screen rank-up titles, and maximum-rank hiding. - Extended US-001 so a self-destructing creeper awards deduplicated progress and normal feedback to every player its explosion hits. - Completed US-004 with an asynchronous self-service progress command, ordinary-player permission, completion output, and command metadata. +- Completed US-005 with UUID-backed offline inspection, atomic progress and rank administration, separate permissions, online-state refresh, and audit logging. diff --git a/design/user-stories/us-005-administer-player-progression.md b/design/user-stories/us-005-administer-player-progression.md index 3a2f6e6..2761ef8 100644 --- a/design/user-stories/us-005-administer-player-progression.md +++ b/design/user-stories/us-005-administer-player-progression.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: Administer player progression" description: Let authorized administrators inspect and modify online or offline player rank and tier progress. -status: backlog +status: done --- # US-005: Administer player progression @@ -11,19 +11,19 @@ As a **server administrator**, I want to inspect and modify player progress so t ## Acceptance criteria -- [ ] `/creeperaura progress ` reports another player's rank, current-tier progress, next requirement, and remaining kills. -- [ ] `/creeperaura set ` sets a non-negative current-tier progress value without implicitly changing rank. -- [ ] `/creeperaura add ` adjusts current-tier progress without allowing a negative result. -- [ ] `/creeperaura rank ` explicitly changes rank and resets current-tier progress to zero. -- [ ] Rank VI never retains current-tier progress. -- [ ] Inspection and modification work for known offline players as well as online players. -- [ ] Players are resolved to stored UUIDs so name changes do not create duplicate progression records. -- [ ] Administrative changes are persisted immediately. -- [ ] An online affected player's feedback and aura state are updated after a change. -- [ ] Administrative commands require distinct, documented permissions suitable for inspection and modification. -- [ ] Unauthorized use does not disclose another player's progression. -- [ ] Invalid player names, ambiguous identities, invalid numbers, and storage failures produce clear responses without partial changes. -- [ ] Successful administrative changes are written to the server log with the actor, target, old value, and new value. +- [x] `/creeperaura progress ` reports another player's rank, current-tier progress, next requirement, and remaining kills. +- [x] `/creeperaura set ` sets a non-negative current-tier progress value without implicitly changing rank. +- [x] `/creeperaura add ` adjusts current-tier progress without allowing a negative result. +- [x] `/creeperaura rank ` explicitly changes rank and resets current-tier progress to zero. +- [x] Rank VI never retains current-tier progress. +- [x] Inspection and modification work for known offline players as well as online players. +- [x] Players are resolved to stored UUIDs so name changes do not create duplicate progression records. +- [x] Administrative changes are persisted immediately. +- [x] An online affected player's feedback and aura state are updated after a change. +- [x] Administrative commands require distinct, documented permissions suitable for inspection and modification. +- [x] Unauthorized use does not disclose another player's progression. +- [x] Invalid player names, ambiguous identities, invalid numbers, and storage failures produce clear responses without partial changes. +- [x] Successful administrative changes are written to the server log with the actor, target, old value, and new value. ## Related diff --git a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java index 3a1044b..4ce65ba 100644 --- a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java +++ b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java @@ -4,6 +4,7 @@ import games.dmg.creeperfear.aura.AuraRules; import games.dmg.creeperfear.aura.CreeperAuraListener; import games.dmg.creeperfear.command.CreeperAuraCommand; import games.dmg.creeperfear.command.ProgressMessages; +import games.dmg.creeperfear.command.ProgressMutations; import games.dmg.creeperfear.feedback.ProgressDisplay; import games.dmg.creeperfear.feedback.ProgressFeedback; import games.dmg.creeperfear.listener.CreeperDeathListener; @@ -38,7 +39,12 @@ public final class CreeperFearPlugin extends JavaPlugin { getServer().getPluginManager().registerEvents( new CreeperAuraListener(progressService, auraRules), this); Objects.requireNonNull(getCommand("creeperaura"), "creeperaura command") - .setExecutor(new CreeperAuraCommand(this, progressService, new ProgressMessages(auraRules))); + .setExecutor(new CreeperAuraCommand( + this, + progressService, + new ProgressMessages(auraRules), + new ProgressMutations(), + progressFeedback)); getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId()) .exceptionally(failure -> { getLogger().log(Level.SEVERE, diff --git a/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java b/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java index 57e2ad4..171bc15 100644 --- a/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java +++ b/src/main/java/games/dmg/creeperfear/command/CreeperAuraCommand.java @@ -1,8 +1,13 @@ package games.dmg.creeperfear.command; +import games.dmg.creeperfear.feedback.ProgressFeedback; import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.PlayerProgress; import games.dmg.creeperfear.progress.ProgressService; +import games.dmg.creeperfear.progress.ProgressService.ProgressChange; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.function.UnaryOperator; import java.util.logging.Level; import org.bukkit.ChatColor; import org.bukkit.command.Command; @@ -15,35 +20,138 @@ public final class CreeperAuraCommand implements CommandExecutor { private final JavaPlugin plugin; private final ProgressService progressService; private final ProgressMessages messages; + private final ProgressMutations mutations; + private final ProgressFeedback feedback; - public CreeperAuraCommand(JavaPlugin plugin, ProgressService progressService, ProgressMessages messages) { + public CreeperAuraCommand( + JavaPlugin plugin, + ProgressService progressService, + ProgressMessages messages, + ProgressMutations mutations, + ProgressFeedback feedback) { this.plugin = plugin; this.progressService = progressService; this.messages = messages; + this.mutations = mutations; + this.feedback = feedback; } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (args.length != 1 || !args[0].equalsIgnoreCase("progress")) { - return false; + if (args.length == 1 && args[0].equalsIgnoreCase("progress")) { + return showSelf(sender); } - if (!(sender instanceof Player player)) { - sender.sendMessage(ChatColor.RED + "This form of the command can only be used by a player."); + if (args.length == 2 && args[0].equalsIgnoreCase("progress")) { + if (!require(sender, "creeperfear.admin.inspect")) return true; + inspect(sender, args[1]); return true; } + if (args.length == 3 && isMutation(args[0])) { + if (!require(sender, "creeperfear.admin.modify")) return true; + mutate(sender, args[0].toLowerCase(), args[1], args[2]); + return true; + } + return false; + } - progressService.find(player.getUniqueId()).whenComplete((stored, failure) -> - plugin.getServer().getScheduler().runTask(plugin, () -> { - if (failure != null) { - plugin.getLogger().log(Level.SEVERE, - "Could not read Creeper Aura progress for " + player.getUniqueId(), failure); - sender.sendMessage(ChatColor.RED + "Creeper Aura progress is temporarily unavailable."); - return; - } - PlayerProgress progress = stored.orElseGet(() -> new PlayerProgress( - player.getUniqueId(), player.getName(), AuraRank.LOCKED, 0)); - sender.sendMessage(ChatColor.GREEN + messages.describe(progress)); - })); + private boolean showSelf(CommandSender sender) { + if (!require(sender, "creeperfear.progress")) return true; + if (!(sender instanceof Player player)) { + sender.sendMessage(ChatColor.RED + "Use /creeperaura progress from the console."); + return true; + } + progressService.find(player.getUniqueId()).whenComplete((stored, failure) -> runMain(() -> { + if (failure != null) { + reportFailure(sender, failure); + return; + } + PlayerProgress progress = stored.orElseGet(() -> new PlayerProgress( + player.getUniqueId(), player.getName(), AuraRank.LOCKED, 0)); + sender.sendMessage(ChatColor.GREEN + messages.describe(progress)); + })); return true; } + + private void inspect(CommandSender sender, String playerName) { + progressService.findByName(playerName).whenComplete((stored, failure) -> runMain(() -> { + if (failure != null) { + reportFailure(sender, failure); + } else if (stored.isEmpty()) { + sender.sendMessage(ChatColor.RED + "No stored Creeper Aura player named " + playerName + "."); + } else { + PlayerProgress progress = stored.orElseThrow(); + sender.sendMessage(ChatColor.GREEN + progress.lastKnownName() + ": " + messages.describe(progress)); + } + })); + } + + private void mutate(CommandSender sender, String operation, String playerName, String value) { + final UnaryOperator mutation; + try { + mutation = switch (operation) { + case "set" -> { + int progress = Integer.parseInt(value); + yield current -> mutations.set(current, progress); + } + case "add" -> { + int adjustment = Integer.parseInt(value); + yield current -> mutations.add(current, adjustment); + } + case "rank" -> { + AuraRank rank = AuraRank.valueOf(value.toUpperCase()); + yield current -> mutations.rank(current, rank); + } + default -> throw new IllegalArgumentException("Unknown operation."); + }; + } catch (IllegalArgumentException exception) { + sender.sendMessage(ChatColor.RED + "Invalid value: " + value + "."); + return; + } + + progressService.updateByName(playerName, mutation).whenComplete((change, failure) -> runMain(() -> { + if (failure != null) { + reportFailure(sender, failure); + return; + } + Optional result = change; + if (result.isEmpty()) { + sender.sendMessage(ChatColor.RED + "No stored Creeper Aura player named " + playerName + "."); + return; + } + ProgressChange applied = result.orElseThrow(); + PlayerProgress after = applied.after(); + sender.sendMessage(ChatColor.GREEN + "Updated " + after.lastKnownName() + ": " + messages.describe(after)); + plugin.getLogger().info("Creeper Aura admin change by " + sender.getName() + + " for " + after.lastKnownName() + " (" + after.playerId() + "): " + + applied.before().rank() + "/" + applied.before().tierKills() + " -> " + + after.rank() + "/" + after.tierKills()); + Player online = plugin.getServer().getPlayer(after.playerId()); + if (online != null) feedback.refreshIfVisible(online, after); + })); + } + + private boolean require(CommandSender sender, String permission) { + if (sender.hasPermission(permission)) return true; + sender.sendMessage(ChatColor.RED + "You do not have permission to do that."); + return false; + } + + private boolean isMutation(String value) { + return value.equalsIgnoreCase("set") || value.equalsIgnoreCase("add") || value.equalsIgnoreCase("rank"); + } + + private void runMain(Runnable action) { + plugin.getServer().getScheduler().runTask(plugin, action); + } + + private void reportFailure(CommandSender sender, Throwable failure) { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() : failure; + if (cause instanceof IllegalArgumentException) { + sender.sendMessage(ChatColor.RED + cause.getMessage()); + return; + } + plugin.getLogger().log(Level.SEVERE, "Creeper Aura command failed", cause); + sender.sendMessage(ChatColor.RED + "Creeper Aura progress is temporarily unavailable."); + } } diff --git a/src/main/java/games/dmg/creeperfear/command/ProgressMutations.java b/src/main/java/games/dmg/creeperfear/command/ProgressMutations.java new file mode 100644 index 0000000..62a245c --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/command/ProgressMutations.java @@ -0,0 +1,29 @@ +package games.dmg.creeperfear.command; + +import games.dmg.creeperfear.progress.AuraRank; +import games.dmg.creeperfear.progress.PlayerProgress; + +public final class ProgressMutations { + public PlayerProgress set(PlayerProgress current, int tierKills) { + if (tierKills < 0) { + throw new IllegalArgumentException("Progress cannot be negative."); + } + if (current.rank().isMaximum() && tierKills != 0) { + throw new IllegalArgumentException("Rank VI cannot retain progress."); + } + return new PlayerProgress( + current.playerId(), current.lastKnownName(), current.rank(), tierKills); + } + + public PlayerProgress add(PlayerProgress current, int adjustment) { + long result = (long) current.tierKills() + adjustment; + if (result < 0 || result > Integer.MAX_VALUE) { + throw new IllegalArgumentException("The resulting progress is outside the allowed range."); + } + return set(current, (int) result); + } + + public PlayerProgress rank(PlayerProgress current, AuraRank rank) { + return new PlayerProgress(current.playerId(), current.lastKnownName(), rank, 0); + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/PlayerLookupException.java b/src/main/java/games/dmg/creeperfear/progress/PlayerLookupException.java new file mode 100644 index 0000000..9d5561c --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/progress/PlayerLookupException.java @@ -0,0 +1,7 @@ +package games.dmg.creeperfear.progress; + +public final class PlayerLookupException extends IllegalArgumentException { + public PlayerLookupException(String message) { + super(message); + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java b/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java index edaceda..9046362 100644 --- a/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java +++ b/src/main/java/games/dmg/creeperfear/progress/ProgressRepository.java @@ -8,6 +8,8 @@ public interface ProgressRepository extends AutoCloseable { Optional find(UUID playerId); + Optional findByName(String playerName); + PlayerProgress save(PlayerProgress progress); @Override diff --git a/src/main/java/games/dmg/creeperfear/progress/ProgressService.java b/src/main/java/games/dmg/creeperfear/progress/ProgressService.java index ec18307..3125d01 100644 --- a/src/main/java/games/dmg/creeperfear/progress/ProgressService.java +++ b/src/main/java/games/dmg/creeperfear/progress/ProgressService.java @@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.function.UnaryOperator; public final class ProgressService implements AutoCloseable { private final ProgressRepository repository; @@ -61,12 +62,29 @@ public final class ProgressService implements AutoCloseable { return CompletableFuture.supplyAsync(() -> repository.find(playerId), executor); } + public CompletableFuture> findByName(String playerName) { + return CompletableFuture.supplyAsync(() -> repository.findByName(playerName), executor); + } + public CompletableFuture save(PlayerProgress progress) { - return CompletableFuture.supplyAsync(() -> { - PlayerProgress saved = repository.save(progress); - onlineProgress.computeIfPresent(progress.playerId(), (ignored, existing) -> saved); - return saved; - }, executor); + return CompletableFuture.supplyAsync(() -> cacheIfOnline(repository.save(progress)), executor); + } + + public CompletableFuture> updateByName( + String playerName, UnaryOperator mutation) { + return CompletableFuture.supplyAsync(() -> repository.findByName(playerName).map(current -> { + PlayerProgress updated = repository.save(mutation.apply(current)); + cacheIfOnline(updated); + return new ProgressChange(current, updated); + }), executor); + } + + private PlayerProgress cacheIfOnline(PlayerProgress progress) { + onlineProgress.computeIfPresent(progress.playerId(), (ignored, existing) -> progress); + return progress; + } + + public record ProgressChange(PlayerProgress before, PlayerProgress after) { } @Override diff --git a/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java b/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java index 5db5287..f5e8dcb 100644 --- a/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java +++ b/src/main/java/games/dmg/creeperfear/progress/SqliteProgressRepository.java @@ -85,6 +85,34 @@ public final class SqliteProgressRepository implements ProgressRepository { } } + @Override + public synchronized Optional findByName(String playerName) { + String sql = """ + SELECT player_uuid, last_known_name, rank, tier_kills + FROM player_progress + WHERE last_known_name = ? COLLATE NOCASE + ORDER BY updated_at DESC + """; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, playerName); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + return Optional.empty(); + } + PlayerProgress progress = readProgress(result); + if (result.next()) { + throw new PlayerLookupException( + "Multiple stored players match " + playerName + "; use a unique current name."); + } + return Optional.of(progress); + } + } catch (PlayerLookupException exception) { + throw exception; + } catch (SQLException | RuntimeException exception) { + throw storageFailure("Could not find progress for player name " + playerName, exception); + } + } + @Override public synchronized PlayerProgress save(PlayerProgress progress) { String sql = """ @@ -126,15 +154,19 @@ public final class SqliteProgressRepository implements ProgressRepository { if (!result.next()) { return Optional.empty(); } - return Optional.of(new PlayerProgress( - UUID.fromString(result.getString("player_uuid")), - result.getString("last_known_name"), - AuraRank.valueOf(result.getString("rank")), - result.getInt("tier_kills"))); + return Optional.of(readProgress(result)); } } } + private PlayerProgress readProgress(ResultSet result) throws SQLException { + return new PlayerProgress( + UUID.fromString(result.getString("player_uuid")), + result.getString("last_known_name"), + AuraRank.valueOf(result.getString("rank")), + result.getInt("tier_kills")); + } + private void rollbackAfterFailure(Throwable original) { try { connection.rollback(); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index df2b7dc..90df74c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,9 +7,14 @@ description: Unlock Creeper Aura ranks by defeating creepers. commands: creeperaura: description: Check and administer Creeper Aura progression. - usage: / progress - permission: creeperfear.progress + usage: / |add |rank > permissions: creeperfear.progress: description: Check personal Creeper Aura progression. default: true + creeperfear.admin.inspect: + description: Inspect another player's Creeper Aura progression. + default: op + creeperfear.admin.modify: + description: Modify another player's Creeper Aura progression. + default: op diff --git a/src/test/java/games/dmg/creeperfear/command/ProgressMutationsTest.java b/src/test/java/games/dmg/creeperfear/command/ProgressMutationsTest.java new file mode 100644 index 0000000..c04d853 --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/command/ProgressMutationsTest.java @@ -0,0 +1,35 @@ +package games.dmg.creeperfear.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import games.dmg.creeperfear.progress.AuraRank; +import games.dmg.creeperfear.progress.PlayerProgress; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ProgressMutationsTest { + private final ProgressMutations mutations = new ProgressMutations(); + private final PlayerProgress current = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.II, 40); + + @Test + void setsAndAdjustsOnlyCurrentTierProgress() { + assertEquals(75, mutations.set(current, 75).tierKills()); + assertEquals(25, mutations.add(current, -15).tierKills()); + assertEquals(AuraRank.II, mutations.set(current, 75).rank()); + } + + @Test + void explicitRankChangesResetProgress() { + PlayerProgress changed = mutations.rank(current, AuraRank.V); + assertEquals(AuraRank.V, changed.rank()); + assertEquals(0, changed.tierKills()); + } + + @Test + void rejectsNegativeResultsAndRankSixProgress() { + assertThrows(IllegalArgumentException.class, () -> mutations.add(current, -41)); + PlayerProgress maximum = mutations.rank(current, AuraRank.VI); + assertThrows(IllegalArgumentException.class, () -> mutations.set(maximum, 1)); + } +} diff --git a/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java b/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java index 7faea54..fd92eba 100644 --- a/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java +++ b/src/test/java/games/dmg/creeperfear/progress/ProgressServiceTest.java @@ -52,6 +52,11 @@ class ProgressServiceTest { return Optional.empty(); } + @Override + public Optional findByName(String playerName) { + return Optional.empty(); + } + @Override public PlayerProgress save(PlayerProgress progress) { return progress; diff --git a/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java b/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java index b249b41..831ed08 100644 --- a/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java +++ b/src/test/java/games/dmg/creeperfear/progress/SqliteProgressRepositoryTest.java @@ -32,6 +32,7 @@ class SqliteProgressRepositoryTest { assertEquals(AuraRank.LOCKED, persisted.rank()); assertEquals(2, persisted.tierKills()); assertEquals("NewName", persisted.lastKnownName()); + assertEquals(playerId, repository.findByName("newname").orElseThrow().playerId()); } }