feat(access): manage spawn access grants
This commit is contained in:
@@ -34,8 +34,13 @@ Each world initially uses its native spawn and a 20-block circular radius. An op
|
||||
/spawnadmin set
|
||||
/spawnadmin distance <blocks>
|
||||
/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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
title: "US-004: Grant and remove spawn 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
|
||||
@@ -11,17 +11,17 @@ As a **server administrator**, I want to grant, inspect, and remove spawn access
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/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`.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] Player state is stored by UUID while retaining the latest known player name for display and lookup.
|
||||
- [x] `/spawnadmin grant <player> <duration>` grants immediate `/spawn` access with the supplied cooldown.
|
||||
- [x] Friendly, case-insensitive duration units are accepted, including values such as `30m`, `2h`, and `1d`.
|
||||
- [x] Zero, negative, malformed, or overflowing grant durations are rejected with an explanatory message.
|
||||
- [x] A custom administrative grant overrides the player's boss-based cooldown tier while the grant exists.
|
||||
- [x] Killing qualifying enemies does not replace an active administrative grant's custom cooldown.
|
||||
- [x] `/spawnadmin remove <player>` removes any administrative grant, recorded enemy progress, and active cooldown from the player.
|
||||
- [x] After removal, the player begins with no access and can earn it again by defeating a qualifying enemy.
|
||||
- [x] `/spawnadmin access [page]` lists all players who currently have natural or granted access, including offline players.
|
||||
- [x] Each access-list entry identifies the player, access source, applicable cooldown, progression tier when relevant, and current remaining cooldown.
|
||||
- [x] Administrative player arguments support online players and previously known offline players when they can be resolved safely.
|
||||
- [x] Player state is stored by UUID while retaining the latest known player name for display and lookup.
|
||||
|
||||
## 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.bukkit.ChatColor;
|
||||
@@ -13,10 +16,22 @@ import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
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.access = access;
|
||||
this.players = players;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -34,6 +49,9 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
|
||||
case "set" -> setCenter(player, arguments);
|
||||
case "distance" -> setDistance(player, arguments);
|
||||
case "info" -> showInfo(player, arguments);
|
||||
case "grant" -> grant(player, arguments);
|
||||
case "remove" -> remove(player, arguments);
|
||||
case "access" -> listAccess(player, arguments);
|
||||
default -> {
|
||||
sendUsage(player, label);
|
||||
yield true;
|
||||
@@ -48,7 +66,7 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
|
||||
return List.of();
|
||||
}
|
||||
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))
|
||||
.toList();
|
||||
}
|
||||
@@ -125,11 +143,109 @@ final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
|
||||
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) {
|
||||
return String.format(Locale.ROOT, "%.1f", coordinate);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
@@ -18,7 +19,11 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
|
||||
YamlSpawnStateRepository repository =
|
||||
new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml"));
|
||||
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");
|
||||
if (adminCommand == null) {
|
||||
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 java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
@@ -30,7 +31,10 @@ final class SpawnAdminCommandTest {
|
||||
SpawnStateManager manager = SpawnStateManager.load(
|
||||
new YamlSpawnStateRepository(temporaryDirectory.resolve("state.yml")));
|
||||
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.onCommand(player, bukkitCommand, "spawnadmin", new String[] {"set"});
|
||||
|
||||
Reference in New Issue
Block a user