feat(access): manage spawn bans
Release / release (push) Failing after 11s
CI / build (push) Successful in 57s

This commit is contained in:
dmg
2026-08-08 13:23:29 -04:00
parent d9051e1661
commit edab7f03db
9 changed files with 194 additions and 13 deletions
+3
View File
@@ -37,6 +37,9 @@ Each world initially uses its native spawn and a 20-block circular radius. An op
/spawnadmin grant <player> <duration> /spawnadmin grant <player> <duration>
/spawnadmin remove <player> /spawnadmin remove <player>
/spawnadmin access [page] /spawnadmin access [page]
/spawnadmin ban <player>
/spawnadmin unban <player>
/spawnadmin bans [page]
``` ```
Grant durations support friendly units and combinations such as `30m`, `2h`, `1d`, or `1h30m`. Grant durations support friendly units and combinations such as `30m`, `2h`, `1d`, or `1h30m`.
+6
View File
@@ -45,3 +45,9 @@ description: Chronological record of significant Trigger Spawn design decisions.
- Added custom `/spawn` cooldown grants with safe, friendly duration parsing. - Added custom `/spawn` cooldown grants with safe, friendly duration parsing.
- Added complete access resets and a paginated view of naturally and administratively eligible players. - 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. - 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.
+10 -10
View File
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-005: Ban and unban spawn access" title: "US-005: Ban and unban spawn access"
description: Let administrators prevent selected players from using or reacquiring spawn teleportation until unbanned. 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 # 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 ## Acceptance criteria
- [ ] `/spawnadmin ban <player>` immediately prevents the player from using `/spawn`. - [x] `/spawnadmin ban <player>` immediately prevents the player from using `/spawn`.
- [ ] Banning a player clears their administrative grant, enemy progress, and active cooldown. - [x] Banning a player clears their administrative grant, enemy progress, and active cooldown.
- [ ] Qualifying kills made while banned are not recorded. - [x] Qualifying kills made while banned are not recorded.
- [ ] A banned player cannot receive an administrative grant until unbanned. - [x] A banned player cannot receive an administrative grant until unbanned.
- [ ] `/spawnadmin bans [page]` displays a paginated list of banned players, including offline players. - [x] `/spawnadmin bans [page]` displays a paginated list of banned players, including offline players.
- [ ] `/spawnadmin unban <player>` removes the ban without restoring any prior grant, enemy progress, or cooldown. - [x] `/spawnadmin unban <player>` 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. - [x] 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. - [x] 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] Ban state is associated with player UUIDs and survives server and plugin restarts.
## Related ## Related
@@ -0,0 +1,6 @@
package games.dmg.triggerspawn;
import java.util.UUID;
record BanEntry(UUID playerId, String playerName) {
}
@@ -20,16 +20,19 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
private final SpawnAreaService spawnAreas; private final SpawnAreaService spawnAreas;
private final AccessService access; private final AccessService access;
private final SpawnBanService bans;
private final PlayerResolver players; private final PlayerResolver players;
private final Clock clock; private final Clock clock;
SpawnAdminCommand( SpawnAdminCommand(
SpawnAreaService spawnAreas, SpawnAreaService spawnAreas,
AccessService access, AccessService access,
SpawnBanService bans,
PlayerResolver players, PlayerResolver players,
Clock clock) { Clock clock) {
this.spawnAreas = spawnAreas; this.spawnAreas = spawnAreas;
this.access = access; this.access = access;
this.bans = bans;
this.players = players; this.players = players;
this.clock = clock; this.clock = clock;
} }
@@ -52,6 +55,9 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
case "grant" -> grant(player, arguments); case "grant" -> grant(player, arguments);
case "remove" -> remove(player, arguments); case "remove" -> remove(player, arguments);
case "access" -> listAccess(player, arguments); case "access" -> listAccess(player, arguments);
case "ban" -> ban(player, arguments);
case "unban" -> unban(player, arguments);
case "bans" -> listBans(player, arguments);
default -> { default -> {
sendUsage(player, label); sendUsage(player, label);
yield true; yield true;
@@ -66,7 +72,7 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
return List.of(); return List.of();
} }
String prefix = arguments[0].toLowerCase(Locale.ROOT); 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)) .filter(value -> value.startsWith(prefix))
.toList(); .toList();
} }
@@ -224,6 +230,69 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
return true; 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 + " <player>");
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<BanEntry> 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) { private static int parsePage(String[] arguments, CommandSender sender) {
if (arguments.length == 1) { if (arguments.length == 1) {
return 1; return 1;
@@ -246,6 +315,6 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(CommandSender sender, String label) { private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label
+ " <set|distance|info|grant|remove|access>"); + " <set|distance|info|grant|remove|access|ban|unban|bans>");
} }
} }
@@ -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<BanEntry> 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();
}
}
@@ -22,6 +22,7 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
SpawnAdminCommand executor = new SpawnAdminCommand( SpawnAdminCommand executor = new SpawnAdminCommand(
new SpawnAreaService(stateManager), new SpawnAreaService(stateManager),
new AccessService(stateManager, settings), new AccessService(stateManager, settings),
new SpawnBanService(stateManager),
new PlayerResolver(getServer(), stateManager), new PlayerResolver(getServer(), stateManager),
Clock.systemUTC()); Clock.systemUTC());
PluginCommand adminCommand = getCommand("spawnadmin"); PluginCommand adminCommand = getCommand("spawnadmin");
@@ -34,7 +34,11 @@ final class SpawnAdminCommandTest {
AccessService access = new AccessService( AccessService access = new AccessService(
manager, PluginSettings.from(new org.bukkit.configuration.MemoryConfiguration())); manager, PluginSettings.from(new org.bukkit.configuration.MemoryConfiguration()));
SpawnAdminCommand command = new SpawnAdminCommand( 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 bukkitCommand = mock(Command.class);
command.onCommand(player, bukkitCommand, "spawnadmin", new String[] {"set"}); command.onCommand(player, bukkitCommand, "spawnadmin", new String[] {"set"});
@@ -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());
}
}