feat(access): manage spawn access grants
Release / release (push) Failing after 11s
CI / build (push) Successful in 2m9s

This commit is contained in:
dmg
2026-08-08 13:21:43 -04:00
parent 18dfa058e5
commit d9051e1661
13 changed files with 435 additions and 18 deletions
+5
View File
@@ -34,8 +34,13 @@ Each world initially uses its native spawn and a 20-block circular radius. An op
/spawnadmin set /spawnadmin set
/spawnadmin distance <blocks> /spawnadmin distance <blocks>
/spawnadmin info /spawnadmin info
/spawnadmin grant <player> <duration>
/spawnadmin remove <player>
/spawnadmin access [page]
``` ```
Grant durations support friendly units and combinations such as `30m`, `2h`, `1d`, or `1h30m`.
For a local versioned build: For a local versioned build:
```bash ```bash
+6
View File
@@ -39,3 +39,9 @@ description: Chronological record of significant Trigger Spawn design decisions.
- Added `/spawnadmin set`, `distance`, and `info` for player-operated configuration of the current world. - Added `/spawnadmin set`, `distance`, and `info` for player-operated configuration of the current world.
- Worlds default to their native spawn and an independently persisted 20-block radius. - Worlds default to their native spawn and an independently persisted 20-block radius.
- Circular target sampling is uniform by area, preserves configured facing, and supports a zero-block radius. - Circular target sampling is uniform by area, preserves configured facing, and supports a zero-block radius.
## 2026-08-08 — Administrative access grants
- 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.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-004: Grant and remove spawn access" title: "US-004: Grant and remove spawn access"
description: Let administrators grant custom spawn cooldowns, inspect access, and completely reset a player's access. description: Let administrators grant custom spawn cooldowns, inspect access, and completely reset a player's access.
status: backlog status: done
--- ---
# US-004: Grant and remove spawn access # US-004: Grant and remove spawn access
@@ -11,17 +11,17 @@ As a **server administrator**, I want to grant, inspect, and remove spawn access
## Acceptance criteria ## Acceptance criteria
- [ ] `/spawnadmin grant <player> <duration>` grants immediate `/spawn` access with the supplied cooldown. - [x] `/spawnadmin grant <player> <duration>` grants immediate `/spawn` access with the supplied cooldown.
- [ ] Friendly, case-insensitive duration units are accepted, including values such as `30m`, `2h`, and `1d`. - [x] Friendly, case-insensitive duration units are accepted, including values such as `30m`, `2h`, and `1d`.
- [ ] Zero, negative, malformed, or overflowing grant durations are rejected with an explanatory message. - [x] Zero, negative, malformed, or overflowing grant durations are rejected with an explanatory message.
- [ ] A custom administrative grant overrides the player's boss-based cooldown tier while the grant exists. - [x] A custom administrative grant overrides the player's boss-based cooldown tier while the grant exists.
- [ ] Killing qualifying enemies does not replace an active administrative grant's custom cooldown. - [x] Killing qualifying enemies does not replace an active administrative grant's custom cooldown.
- [ ] `/spawnadmin remove <player>` removes any administrative grant, recorded enemy progress, and active cooldown from the player. - [x] `/spawnadmin remove <player>` removes any administrative grant, recorded enemy progress, and active cooldown from the player.
- [ ] After removal, the player begins with no access and can earn it again by defeating a qualifying enemy. - [x] After removal, the player begins with no access and can earn it again by defeating a qualifying enemy.
- [ ] `/spawnadmin access [page]` lists all players who currently have natural or granted access, including offline players. - [x] `/spawnadmin access [page]` lists all players who currently have natural or granted access, including offline players.
- [ ] Each access-list entry identifies the player, access source, applicable cooldown, progression tier when relevant, and current remaining cooldown. - [x] Each access-list entry identifies the player, access source, applicable cooldown, progression tier when relevant, and current remaining cooldown.
- [ ] Administrative player arguments support online players and previously known offline players when they can be resolved safely. - [x] Administrative player arguments support online players and previously known offline players when they can be resolved safely.
- [ ] Player state is stored by UUID while retaining the latest known player name for display and lookup. - [x] Player state is stored by UUID while retaining the latest known player name for display and lookup.
## Related ## Related
@@ -0,0 +1,13 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import java.util.UUID;
record AccessEntry(
UUID playerId,
String playerName,
String source,
Duration cooldown,
Duration remainingCooldown,
int defeatedBosses) {
}
@@ -0,0 +1,97 @@
package games.dmg.triggerspawn;
import java.io.IOException;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
final class AccessService {
private final SpawnStateManager stateManager;
private final PluginSettings settings;
AccessService(SpawnStateManager stateManager, PluginSettings settings) {
this.stateManager = stateManager;
this.settings = settings;
}
void grant(PlayerIdentity identity, Duration cooldown) throws IOException {
if (cooldown.isZero() || cooldown.isNegative()) {
throw new IllegalArgumentException("grant cooldown must be positive");
}
PlayerState current = stateManager.player(identity.playerId(), identity.latestName());
if (current.banned()) {
throw new IllegalStateException("banned players cannot receive access");
}
stateManager.putPlayer(new PlayerState(
identity.playerId(),
identity.latestName(),
current.defeatedBosses(),
Optional.of(cooldown),
false,
current.lastSpawnUse()));
}
void remove(PlayerIdentity identity) throws IOException {
PlayerState current = stateManager.player(identity.playerId(), identity.latestName());
stateManager.putPlayer(new PlayerState(
identity.playerId(),
identity.latestName(),
Set.of(),
Optional.empty(),
current.banned(),
Optional.empty()));
}
boolean hasAccess(PlayerState player) {
return !player.banned()
&& (player.grantedCooldown().isPresent() || !player.defeatedBosses().isEmpty());
}
Optional<Duration> cooldownFor(PlayerState player) {
if (!hasAccess(player)) {
return Optional.empty();
}
if (player.grantedCooldown().isPresent()) {
return player.grantedCooldown();
}
return Optional.of(switch (player.defeatedBosses().size()) {
case 1 -> settings.oneKillCooldown();
case 2 -> settings.twoKillCooldown();
default -> settings.threeKillCooldown();
});
}
Duration remainingCooldown(PlayerState player, Instant now) {
Optional<Duration> cooldown = cooldownFor(player);
if (cooldown.isEmpty() || player.lastSpawnUse().isEmpty()) {
return Duration.ZERO;
}
try {
Instant availableAt = player.lastSpawnUse().orElseThrow().plus(cooldown.orElseThrow());
return availableAt.isAfter(now) ? Duration.between(now, availableAt) : Duration.ZERO;
} catch (DateTimeException | ArithmeticException exception) {
return cooldown.orElseThrow();
}
}
List<AccessEntry> listAccess(Instant now) {
return stateManager.snapshot().players().values().stream()
.filter(this::hasAccess)
.map(player -> new AccessEntry(
player.playerId(),
player.latestName(),
player.grantedCooldown().isPresent()
? "grant"
: player.defeatedBosses().size() + " boss"
+ (player.defeatedBosses().size() == 1 ? "" : "es"),
cooldownFor(player).orElseThrow(),
remainingCooldown(player, now),
player.defeatedBosses().size()))
.sorted(Comparator.comparing(AccessEntry::playerName, String.CASE_INSENSITIVE_ORDER))
.toList();
}
}
@@ -0,0 +1,43 @@
package games.dmg.triggerspawn;
import java.time.Duration;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
final class FriendlyDurationParser {
private static final Pattern PART = Pattern.compile("(\\d+)([smhd])");
private FriendlyDurationParser() {
}
static Duration parse(String value) {
String normalized = value.toLowerCase(Locale.ROOT).replace(" ", "");
Matcher matcher = PART.matcher(normalized);
int position = 0;
long totalSeconds = 0;
try {
while (matcher.find()) {
if (matcher.start() != position) {
throw new IllegalArgumentException("invalid duration");
}
long amount = Long.parseLong(matcher.group(1));
long multiplier = switch (matcher.group(2)) {
case "s" -> 1;
case "m" -> 60;
case "h" -> 3_600;
case "d" -> 86_400;
default -> throw new IllegalArgumentException("invalid duration unit");
};
totalSeconds = Math.addExact(totalSeconds, Math.multiplyExact(amount, multiplier));
position = matcher.end();
}
} catch (ArithmeticException | NumberFormatException exception) {
throw new IllegalArgumentException("duration is too large", exception);
}
if (position != normalized.length() || position == 0 || totalSeconds <= 0) {
throw new IllegalArgumentException("duration must be positive and use s, m, h, or d");
}
return Duration.ofSeconds(totalSeconds);
}
}
@@ -0,0 +1,11 @@
package games.dmg.triggerspawn;
import java.util.UUID;
record PlayerIdentity(UUID playerId, String latestName) {
PlayerIdentity {
if (playerId == null || latestName == null || latestName.isBlank()) {
throw new IllegalArgumentException("player identity requires a UUID and name");
}
}
}
@@ -0,0 +1,43 @@
package games.dmg.triggerspawn;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.OfflinePlayer;
import org.bukkit.Server;
import org.bukkit.entity.Player;
final class PlayerResolver {
private final Server server;
private final SpawnStateManager stateManager;
PlayerResolver(Server server, SpawnStateManager stateManager) {
this.server = server;
this.stateManager = stateManager;
}
Optional<PlayerIdentity> resolve(String value) {
Player online = server.getPlayerExact(value);
if (online != null) {
return Optional.of(new PlayerIdentity(online.getUniqueId(), online.getName()));
}
for (PlayerState player : stateManager.snapshot().players().values()) {
if (player.latestName().equalsIgnoreCase(value)) {
return Optional.of(new PlayerIdentity(player.playerId(), player.latestName()));
}
}
for (OfflinePlayer player : server.getOfflinePlayers()) {
String name = player.getName();
if (name != null && name.equalsIgnoreCase(value)) {
return Optional.of(new PlayerIdentity(player.getUniqueId(), name));
}
}
try {
UUID id = UUID.fromString(value);
OfflinePlayer player = server.getOfflinePlayer(id);
String name = player.getName();
return name == null ? Optional.empty() : Optional.of(new PlayerIdentity(id, name));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -1,6 +1,9 @@
package games.dmg.triggerspawn; package games.dmg.triggerspawn;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
@@ -13,10 +16,22 @@ import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
final class SpawnAdminCommand implements CommandExecutor, TabCompleter { final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
private final SpawnAreaService spawnAreas; private static final int PAGE_SIZE = 10;
SpawnAdminCommand(SpawnAreaService spawnAreas) { private final SpawnAreaService spawnAreas;
private final AccessService access;
private final PlayerResolver players;
private final Clock clock;
SpawnAdminCommand(
SpawnAreaService spawnAreas,
AccessService access,
PlayerResolver players,
Clock clock) {
this.spawnAreas = spawnAreas; this.spawnAreas = spawnAreas;
this.access = access;
this.players = players;
this.clock = clock;
} }
@Override @Override
@@ -34,6 +49,9 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
case "set" -> setCenter(player, arguments); case "set" -> setCenter(player, arguments);
case "distance" -> setDistance(player, arguments); case "distance" -> setDistance(player, arguments);
case "info" -> showInfo(player, arguments); case "info" -> showInfo(player, arguments);
case "grant" -> grant(player, arguments);
case "remove" -> remove(player, arguments);
case "access" -> listAccess(player, arguments);
default -> { default -> {
sendUsage(player, label); sendUsage(player, label);
yield true; yield true;
@@ -48,7 +66,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").stream() return List.of("set", "distance", "info", "grant", "remove", "access").stream()
.filter(value -> value.startsWith(prefix)) .filter(value -> value.startsWith(prefix))
.toList(); .toList();
} }
@@ -125,11 +143,109 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
return true; return true;
} }
private boolean grant(Player player, String[] arguments) {
if (arguments.length != 3) {
player.sendMessage(ChatColor.RED + "Usage: /spawnadmin grant <player> <duration>");
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;
}
Duration cooldown;
try {
cooldown = FriendlyDurationParser.parse(arguments[2]);
access.grant(identity, cooldown);
player.sendMessage(ChatColor.GREEN + "Granted /spawn to " + identity.latestName()
+ " with a " + DurationFormatter.format(cooldown) + " cooldown.");
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Duration must be positive and use s, m, h, or d.");
} catch (IllegalStateException exception) {
player.sendMessage(ChatColor.RED + identity.latestName()
+ " is banned from /spawn and cannot receive a grant.");
} catch (IOException exception) {
player.sendMessage(ChatColor.RED + "Could not save the access grant.");
}
return true;
}
private boolean remove(Player player, String[] arguments) {
if (arguments.length != 2) {
player.sendMessage(ChatColor.RED + "Usage: /spawnadmin remove <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 {
access.remove(identity);
player.sendMessage(ChatColor.GREEN + "Removed and reset /spawn access for "
+ identity.latestName() + ".");
} catch (IOException exception) {
player.sendMessage(ChatColor.RED + "Could not save the access reset.");
}
return true;
}
private boolean listAccess(Player player, String[] arguments) {
if (arguments.length > 2) {
player.sendMessage(ChatColor.RED + "Usage: /spawnadmin access [page]");
return true;
}
int page = parsePage(arguments, player);
if (page < 1) {
return true;
}
List<AccessEntry> entries = access.listAccess(Instant.now(clock));
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 + "Players with /spawn access — page "
+ page + "/" + pages + ":");
int start = (page - 1) * PAGE_SIZE;
for (AccessEntry entry : entries.subList(start, Math.min(start + PAGE_SIZE, entries.size()))) {
String remaining = entry.remainingCooldown().isZero()
? "ready"
: DurationFormatter.format(entry.remainingCooldown()) + " remaining";
player.sendMessage(ChatColor.YELLOW + entry.playerName() + ChatColor.GRAY
+ "" + entry.source() + ", "
+ DurationFormatter.format(entry.cooldown()) + ", " + remaining);
}
if (entries.isEmpty()) {
player.sendMessage(ChatColor.GRAY + "No players currently have access.");
}
return true;
}
private static int parsePage(String[] arguments, CommandSender sender) {
if (arguments.length == 1) {
return 1;
}
try {
int page = Integer.parseInt(arguments[1]);
if (page < 1) {
throw new NumberFormatException("non-positive page");
}
return page;
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "Page must be a positive whole number.");
return -1;
}
}
private static String format(double coordinate) { private static String format(double coordinate) {
return String.format(Locale.ROOT, "%.1f", coordinate); return String.format(Locale.ROOT, "%.1f", coordinate);
} }
private static void sendUsage(CommandSender sender, String label) { private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label + " <set|distance|info>"); sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label
+ " <set|distance|info|grant|remove|access>");
} }
} }
@@ -1,6 +1,7 @@
package games.dmg.triggerspawn; package games.dmg.triggerspawn;
import java.io.IOException; import java.io.IOException;
import java.time.Clock;
import java.util.logging.Level; import java.util.logging.Level;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -18,7 +19,11 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
YamlSpawnStateRepository repository = YamlSpawnStateRepository repository =
new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml")); new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml"));
stateManager = SpawnStateManager.load(repository); stateManager = SpawnStateManager.load(repository);
SpawnAdminCommand executor = new SpawnAdminCommand(new SpawnAreaService(stateManager)); SpawnAdminCommand executor = new SpawnAdminCommand(
new SpawnAreaService(stateManager),
new AccessService(stateManager, settings),
new PlayerResolver(getServer(), stateManager),
Clock.systemUTC());
PluginCommand adminCommand = getCommand("spawnadmin"); PluginCommand adminCommand = getCommand("spawnadmin");
if (adminCommand == null) { if (adminCommand == null) {
throw new IllegalStateException("spawnadmin is missing from plugin.yml"); throw new IllegalStateException("spawnadmin is missing from plugin.yml");
@@ -0,0 +1,50 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.bukkit.configuration.MemoryConfiguration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class AccessServiceTest {
@TempDir
Path temporaryDirectory;
@Test
void customGrantOverridesTierAndRemoveCompletelyResetsPlayer() 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, BossType.WITHER),
Optional.empty(),
false,
Optional.of(Instant.parse("2026-08-08T10:00:00Z"))));
AccessService service = new AccessService(
manager, PluginSettings.from(new MemoryConfiguration()));
service.grant(new PlayerIdentity(playerId, "Alex"), Duration.ofMinutes(30));
PlayerState granted = manager.snapshot().players().get(playerId);
assertEquals(Duration.ofMinutes(30), service.cooldownFor(granted).orElseThrow());
assertEquals("grant", service.listAccess(Instant.parse("2026-08-08T10:10:00Z")).get(0).source());
service.remove(new PlayerIdentity(playerId, "Alex"));
PlayerState reset = manager.snapshot().players().get(playerId);
assertTrue(reset.defeatedBosses().isEmpty());
assertTrue(reset.grantedCooldown().isEmpty());
assertTrue(reset.lastSpawnUse().isEmpty());
assertFalse(service.hasAccess(reset));
}
}
@@ -0,0 +1,24 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Duration;
import org.junit.jupiter.api.Test;
final class FriendlyDurationParserTest {
@Test
void parsesFriendlyCaseInsensitiveDurations() {
assertEquals(Duration.ofMinutes(30), FriendlyDurationParser.parse("30m"));
assertEquals(Duration.ofHours(2), FriendlyDurationParser.parse("2H"));
assertEquals(Duration.ofDays(1), FriendlyDurationParser.parse("1d"));
assertEquals(Duration.ofMinutes(90), FriendlyDurationParser.parse("1h30m"));
}
@Test
void rejectsUnsafeDurations() {
assertThrows(IllegalArgumentException.class, () -> FriendlyDurationParser.parse("0s"));
assertThrows(IllegalArgumentException.class, () -> FriendlyDurationParser.parse("later"));
assertThrows(IllegalArgumentException.class, () -> FriendlyDurationParser.parse("999999999999999999d"));
}
}
@@ -6,6 +6,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Clock;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.World; import org.bukkit.World;
@@ -30,7 +31,10 @@ final class SpawnAdminCommandTest {
SpawnStateManager manager = SpawnStateManager.load( SpawnStateManager manager = SpawnStateManager.load(
new YamlSpawnStateRepository(temporaryDirectory.resolve("state.yml"))); new YamlSpawnStateRepository(temporaryDirectory.resolve("state.yml")));
SpawnAreaService service = new SpawnAreaService(manager); SpawnAreaService service = new SpawnAreaService(manager);
SpawnAdminCommand command = new SpawnAdminCommand(service); AccessService access = new AccessService(
manager, PluginSettings.from(new org.bukkit.configuration.MemoryConfiguration()));
SpawnAdminCommand command = new SpawnAdminCommand(
service, access, 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"});