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 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`.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user