feat(admin): manage player progression
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 <player>` reports another player's rank, current-tier progress, next requirement, and remaining kills.
|
||||
- [ ] `/creeperaura set <player> <progress>` sets a non-negative current-tier progress value without implicitly changing rank.
|
||||
- [ ] `/creeperaura add <player> <progress>` adjusts current-tier progress without allowing a negative result.
|
||||
- [ ] `/creeperaura rank <player> <locked|I|II|III|IV|V|VI>` 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 <player>` reports another player's rank, current-tier progress, next requirement, and remaining kills.
|
||||
- [x] `/creeperaura set <player> <progress>` sets a non-negative current-tier progress value without implicitly changing rank.
|
||||
- [x] `/creeperaura add <player> <progress>` adjusts current-tier progress without allowing a negative result.
|
||||
- [x] `/creeperaura rank <player> <locked|I|II|III|IV|V|VI>` 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <player> 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<PlayerProgress> 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<ProgressChange> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.creeperfear.progress;
|
||||
|
||||
public final class PlayerLookupException extends IllegalArgumentException {
|
||||
public PlayerLookupException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ public interface ProgressRepository extends AutoCloseable {
|
||||
|
||||
Optional<PlayerProgress> find(UUID playerId);
|
||||
|
||||
Optional<PlayerProgress> findByName(String playerName);
|
||||
|
||||
PlayerProgress save(PlayerProgress progress);
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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<Optional<PlayerProgress>> findByName(String playerName) {
|
||||
return CompletableFuture.supplyAsync(() -> repository.findByName(playerName), executor);
|
||||
}
|
||||
|
||||
public CompletableFuture<PlayerProgress> 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<Optional<ProgressChange>> updateByName(
|
||||
String playerName, UnaryOperator<PlayerProgress> 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
|
||||
|
||||
@@ -85,6 +85,34 @@ public final class SqliteProgressRepository implements ProgressRepository {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Optional<PlayerProgress> 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();
|
||||
|
||||
@@ -7,9 +7,14 @@ description: Unlock Creeper Aura ranks by defeating creepers.
|
||||
commands:
|
||||
creeperaura:
|
||||
description: Check and administer Creeper Aura progression.
|
||||
usage: /<command> progress
|
||||
permission: creeperfear.progress
|
||||
usage: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ class ProgressServiceTest {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<PlayerProgress> findByName(String playerName) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayerProgress save(PlayerProgress progress) {
|
||||
return progress;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user