From edab7f03db928bbf789503edb5df73b1cacb753b Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 13:23:29 -0400 Subject: [PATCH] feat(access): manage spawn bans --- README.md | 3 + design/log.md | 6 ++ .../user-stories/us-005-manage-spawn-bans.md | 20 ++--- .../java/games/dmg/triggerspawn/BanEntry.java | 6 ++ .../dmg/triggerspawn/SpawnAdminCommand.java | 73 ++++++++++++++++++- .../dmg/triggerspawn/SpawnBanService.java | 42 +++++++++++ .../dmg/triggerspawn/TriggerSpawnPlugin.java | 1 + .../triggerspawn/SpawnAdminCommandTest.java | 6 +- .../dmg/triggerspawn/SpawnBanServiceTest.java | 50 +++++++++++++ 9 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 src/main/java/games/dmg/triggerspawn/BanEntry.java create mode 100644 src/main/java/games/dmg/triggerspawn/SpawnBanService.java create mode 100644 src/test/java/games/dmg/triggerspawn/SpawnBanServiceTest.java diff --git a/README.md b/README.md index b94a72b..d120929 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ Each world initially uses its native spawn and a 20-block circular radius. An op /spawnadmin grant /spawnadmin remove /spawnadmin access [page] +/spawnadmin ban +/spawnadmin unban +/spawnadmin bans [page] ``` Grant durations support friendly units and combinations such as `30m`, `2h`, `1d`, or `1h30m`. diff --git a/design/log.md b/design/log.md index 26ad83c..cebeaa2 100644 --- a/design/log.md +++ b/design/log.md @@ -45,3 +45,9 @@ description: Chronological record of significant Trigger Spawn design decisions. - Added custom `/spawn` cooldown grants with safe, friendly duration parsing. - Added complete access resets and a paginated view of naturally and administratively eligible players. - Administrative targets resolve online players, persisted names, server-known offline players, and known UUIDs while state remains keyed by UUID. + +## 2026-08-08 — Spawn bans + +- Added persistent `/spawn` bans, unbans, and paginated ban listing for online and resolvable offline players. +- Banning immediately clears grants, boss progress, and cooldown state; unbanning restores none of them. +- Banned player state cannot retain natural or granted access. diff --git a/design/user-stories/us-005-manage-spawn-bans.md b/design/user-stories/us-005-manage-spawn-bans.md index 26e28ce..717b9e2 100644 --- a/design/user-stories/us-005-manage-spawn-bans.md +++ b/design/user-stories/us-005-manage-spawn-bans.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: Ban and unban spawn access" description: Let administrators prevent selected players from using or reacquiring spawn teleportation until unbanned. -status: backlog +status: done --- # US-005: Ban and unban spawn access @@ -11,15 +11,15 @@ As a **server administrator**, I want to ban and unban players from spawn telepo ## Acceptance criteria -- [ ] `/spawnadmin ban ` immediately prevents the player from using `/spawn`. -- [ ] Banning a player clears their administrative grant, enemy progress, and active cooldown. -- [ ] Qualifying kills made while banned are not recorded. -- [ ] A banned player cannot receive an administrative grant until unbanned. -- [ ] `/spawnadmin bans [page]` displays a paginated list of banned players, including offline players. -- [ ] `/spawnadmin unban ` removes the ban without restoring any prior grant, enemy progress, or cooldown. -- [ ] An unbanned player begins with no access and can earn access again by defeating a qualifying enemy or receiving a new grant. -- [ ] Ban and unban commands support online players and previously known offline players when they can be resolved safely. -- [ ] Ban state is associated with player UUIDs and survives server and plugin restarts. +- [x] `/spawnadmin ban ` immediately prevents the player from using `/spawn`. +- [x] Banning a player clears their administrative grant, enemy progress, and active cooldown. +- [x] Qualifying kills made while banned are not recorded. +- [x] A banned player cannot receive an administrative grant until unbanned. +- [x] `/spawnadmin bans [page]` displays a paginated list of banned players, including offline players. +- [x] `/spawnadmin unban ` removes the ban without restoring any prior grant, enemy progress, or cooldown. +- [x] An unbanned player begins with no access and can earn access again by defeating a qualifying enemy or receiving a new grant. +- [x] Ban and unban commands support online players and previously known offline players when they can be resolved safely. +- [x] Ban state is associated with player UUIDs and survives server and plugin restarts. ## Related diff --git a/src/main/java/games/dmg/triggerspawn/BanEntry.java b/src/main/java/games/dmg/triggerspawn/BanEntry.java new file mode 100644 index 0000000..8bc7f94 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/BanEntry.java @@ -0,0 +1,6 @@ +package games.dmg.triggerspawn; + +import java.util.UUID; + +record BanEntry(UUID playerId, String playerName) { +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java b/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java index 10efc99..cc1e312 100644 --- a/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java +++ b/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java @@ -20,16 +20,19 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter { private final SpawnAreaService spawnAreas; private final AccessService access; + private final SpawnBanService bans; private final PlayerResolver players; private final Clock clock; SpawnAdminCommand( SpawnAreaService spawnAreas, AccessService access, + SpawnBanService bans, PlayerResolver players, Clock clock) { this.spawnAreas = spawnAreas; this.access = access; + this.bans = bans; this.players = players; this.clock = clock; } @@ -52,6 +55,9 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter { case "grant" -> grant(player, arguments); case "remove" -> remove(player, arguments); case "access" -> listAccess(player, arguments); + case "ban" -> ban(player, arguments); + case "unban" -> unban(player, arguments); + case "bans" -> listBans(player, arguments); default -> { sendUsage(player, label); yield true; @@ -66,7 +72,7 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter { return List.of(); } String prefix = arguments[0].toLowerCase(Locale.ROOT); - return List.of("set", "distance", "info", "grant", "remove", "access").stream() + return List.of("set", "distance", "info", "grant", "remove", "access", "ban", "unban", "bans").stream() .filter(value -> value.startsWith(prefix)) .toList(); } @@ -224,6 +230,69 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter { return true; } + private boolean ban(Player player, String[] arguments) { + return changeBan(player, arguments, true); + } + + private boolean unban(Player player, String[] arguments) { + return changeBan(player, arguments, false); + } + + private boolean changeBan(Player player, String[] arguments, boolean banned) { + String operation = banned ? "ban" : "unban"; + if (arguments.length != 2) { + player.sendMessage(ChatColor.RED + "Usage: /spawnadmin " + operation + " "); + return true; + } + PlayerIdentity identity = players.resolve(arguments[1]).orElse(null); + if (identity == null) { + player.sendMessage(ChatColor.RED + "No online or previously known player matches '" + + arguments[1] + "'."); + return true; + } + try { + if (banned) { + bans.ban(identity); + player.sendMessage(ChatColor.GREEN + "Banned " + identity.latestName() + + " from /spawn and reset their progress."); + } else { + bans.unban(identity); + player.sendMessage(ChatColor.GREEN + "Unbanned " + identity.latestName() + + "; they begin with no /spawn progress."); + } + } catch (IOException exception) { + player.sendMessage(ChatColor.RED + "Could not save the " + operation + "."); + } + return true; + } + + private boolean listBans(Player player, String[] arguments) { + if (arguments.length > 2) { + player.sendMessage(ChatColor.RED + "Usage: /spawnadmin bans [page]"); + return true; + } + int page = parsePage(arguments, player); + if (page < 1) { + return true; + } + List entries = bans.listBans(); + int pages = Math.max(1, (entries.size() + PAGE_SIZE - 1) / PAGE_SIZE); + if (page > pages) { + player.sendMessage(ChatColor.RED + "Page must be between 1 and " + pages + "."); + return true; + } + player.sendMessage(ChatColor.GOLD + "/spawn bans — page " + page + "/" + pages + ":"); + int start = (page - 1) * PAGE_SIZE; + for (BanEntry entry : entries.subList(start, Math.min(start + PAGE_SIZE, entries.size()))) { + player.sendMessage(ChatColor.RED + entry.playerName() + ChatColor.GRAY + + " (" + entry.playerId() + ")"); + } + if (entries.isEmpty()) { + player.sendMessage(ChatColor.GRAY + "No players are banned from /spawn."); + } + return true; + } + private static int parsePage(String[] arguments, CommandSender sender) { if (arguments.length == 1) { return 1; @@ -246,6 +315,6 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter { private static void sendUsage(CommandSender sender, String label) { sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label - + " "); + + " "); } } diff --git a/src/main/java/games/dmg/triggerspawn/SpawnBanService.java b/src/main/java/games/dmg/triggerspawn/SpawnBanService.java new file mode 100644 index 0000000..e3252f4 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SpawnBanService.java @@ -0,0 +1,42 @@ +package games.dmg.triggerspawn; + +import java.io.IOException; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +final class SpawnBanService { + private final SpawnStateManager stateManager; + + SpawnBanService(SpawnStateManager stateManager) { + this.stateManager = stateManager; + } + + void ban(PlayerIdentity identity) throws IOException { + stateManager.putPlayer(new PlayerState( + identity.playerId(), + identity.latestName(), + Set.of(), + Optional.empty(), + true, + Optional.empty())); + } + + void unban(PlayerIdentity identity) throws IOException { + stateManager.putPlayer(PlayerState.newPlayer(identity.playerId(), identity.latestName())); + } + + boolean isBanned(PlayerIdentity identity) { + PlayerState state = stateManager.snapshot().players().get(identity.playerId()); + return state != null && state.banned(); + } + + List listBans() { + return stateManager.snapshot().players().values().stream() + .filter(PlayerState::banned) + .map(player -> new BanEntry(player.playerId(), player.latestName())) + .sorted(Comparator.comparing(BanEntry::playerName, String.CASE_INSENSITIVE_ORDER)) + .toList(); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index 9ce70a5..16fc378 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -22,6 +22,7 @@ public final class TriggerSpawnPlugin extends JavaPlugin { SpawnAdminCommand executor = new SpawnAdminCommand( new SpawnAreaService(stateManager), new AccessService(stateManager, settings), + new SpawnBanService(stateManager), new PlayerResolver(getServer(), stateManager), Clock.systemUTC()); PluginCommand adminCommand = getCommand("spawnadmin"); diff --git a/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java b/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java index 4120347..b4b8d27 100644 --- a/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java +++ b/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java @@ -34,7 +34,11 @@ final class SpawnAdminCommandTest { AccessService access = new AccessService( manager, PluginSettings.from(new org.bukkit.configuration.MemoryConfiguration())); SpawnAdminCommand command = new SpawnAdminCommand( - service, access, mock(PlayerResolver.class), Clock.systemUTC()); + service, + access, + new SpawnBanService(manager), + mock(PlayerResolver.class), + Clock.systemUTC()); Command bukkitCommand = mock(Command.class); command.onCommand(player, bukkitCommand, "spawnadmin", new String[] {"set"}); diff --git a/src/test/java/games/dmg/triggerspawn/SpawnBanServiceTest.java b/src/test/java/games/dmg/triggerspawn/SpawnBanServiceTest.java new file mode 100644 index 0000000..899e22f --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/SpawnBanServiceTest.java @@ -0,0 +1,50 @@ +package games.dmg.triggerspawn; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class SpawnBanServiceTest { + @TempDir + Path temporaryDirectory; + + @Test + void banClearsAccessAndUnbanRestoresNoProgress() throws Exception { + UUID playerId = UUID.randomUUID(); + Path stateFile = temporaryDirectory.resolve("state.yml"); + SpawnStateManager manager = SpawnStateManager.load(new YamlSpawnStateRepository(stateFile)); + manager.putPlayer(new PlayerState( + playerId, + "Alex", + Set.of(BossType.WARDEN), + Optional.of(Duration.ofMinutes(10)), + false, + Optional.of(Instant.now()))); + SpawnBanService bans = new SpawnBanService(manager); + PlayerIdentity identity = new PlayerIdentity(playerId, "Alex"); + + bans.ban(identity); + + PlayerState banned = manager.snapshot().players().get(playerId); + assertTrue(banned.banned()); + assertTrue(banned.defeatedBosses().isEmpty()); + assertTrue(banned.grantedCooldown().isEmpty()); + assertTrue(banned.lastSpawnUse().isEmpty()); + assertTrue(bans.listBans().stream().anyMatch(entry -> entry.playerId().equals(playerId))); + + bans.unban(identity); + + PlayerState unbanned = manager.snapshot().players().get(playerId); + assertFalse(unbanned.banned()); + assertTrue(unbanned.defeatedBosses().isEmpty()); + assertTrue(unbanned.grantedCooldown().isEmpty()); + } +}