feat(access): manage spawn bans
This commit is contained in:
@@ -37,6 +37,9 @@ Each world initially uses its native spawn and a 20-block circular radius. An op
|
||||
/spawnadmin grant <player> <duration>
|
||||
/spawnadmin remove <player>
|
||||
/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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <player>` 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 <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.
|
||||
- [ ] 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 <player>` 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 <player>` 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
|
||||
|
||||
|
||||
@@ -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 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 + " <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) {
|
||||
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
|
||||
+ " <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(
|
||||
new SpawnAreaService(stateManager),
|
||||
new AccessService(stateManager, settings),
|
||||
new SpawnBanService(stateManager),
|
||||
new PlayerResolver(getServer(), stateManager),
|
||||
Clock.systemUTC());
|
||||
PluginCommand adminCommand = getCommand("spawnadmin");
|
||||
|
||||
@@ -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"});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user