From d9051e16612933e68d92399e1d9ca2af1663e28d Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 13:21:43 -0400 Subject: [PATCH] feat(access): manage spawn access grants --- README.md | 5 + design/log.md | 6 + .../us-004-manage-spawn-access.md | 24 ++-- .../games/dmg/triggerspawn/AccessEntry.java | 13 ++ .../games/dmg/triggerspawn/AccessService.java | 97 ++++++++++++++ .../triggerspawn/FriendlyDurationParser.java | 43 ++++++ .../dmg/triggerspawn/PlayerIdentity.java | 11 ++ .../dmg/triggerspawn/PlayerResolver.java | 43 ++++++ .../dmg/triggerspawn/SpawnAdminCommand.java | 124 +++++++++++++++++- .../dmg/triggerspawn/TriggerSpawnPlugin.java | 7 +- .../dmg/triggerspawn/AccessServiceTest.java | 50 +++++++ .../FriendlyDurationParserTest.java | 24 ++++ .../triggerspawn/SpawnAdminCommandTest.java | 6 +- 13 files changed, 435 insertions(+), 18 deletions(-) create mode 100644 src/main/java/games/dmg/triggerspawn/AccessEntry.java create mode 100644 src/main/java/games/dmg/triggerspawn/AccessService.java create mode 100644 src/main/java/games/dmg/triggerspawn/FriendlyDurationParser.java create mode 100644 src/main/java/games/dmg/triggerspawn/PlayerIdentity.java create mode 100644 src/main/java/games/dmg/triggerspawn/PlayerResolver.java create mode 100644 src/test/java/games/dmg/triggerspawn/AccessServiceTest.java create mode 100644 src/test/java/games/dmg/triggerspawn/FriendlyDurationParserTest.java diff --git a/README.md b/README.md index 11eb881..b94a72b 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,13 @@ Each world initially uses its native spawn and a 20-block circular radius. An op /spawnadmin set /spawnadmin distance /spawnadmin info +/spawnadmin grant +/spawnadmin remove +/spawnadmin access [page] ``` +Grant durations support friendly units and combinations such as `30m`, `2h`, `1d`, or `1h30m`. + For a local versioned build: ```bash diff --git a/design/log.md b/design/log.md index 281b91c..26ad83c 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-004-manage-spawn-access.md b/design/user-stories/us-004-manage-spawn-access.md index d5d8131..c3236e8 100644 --- a/design/user-stories/us-004-manage-spawn-access.md +++ b/design/user-stories/us-004-manage-spawn-access.md @@ -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 ` 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 ` 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 ` 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 ` 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 diff --git a/src/main/java/games/dmg/triggerspawn/AccessEntry.java b/src/main/java/games/dmg/triggerspawn/AccessEntry.java new file mode 100644 index 0000000..653f143 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/AccessEntry.java @@ -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) { +} diff --git a/src/main/java/games/dmg/triggerspawn/AccessService.java b/src/main/java/games/dmg/triggerspawn/AccessService.java new file mode 100644 index 0000000..decf9d8 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/AccessService.java @@ -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 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 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 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(); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/FriendlyDurationParser.java b/src/main/java/games/dmg/triggerspawn/FriendlyDurationParser.java new file mode 100644 index 0000000..8fdf41c --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/FriendlyDurationParser.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/PlayerIdentity.java b/src/main/java/games/dmg/triggerspawn/PlayerIdentity.java new file mode 100644 index 0000000..9779f7a --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/PlayerIdentity.java @@ -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"); + } + } +} diff --git a/src/main/java/games/dmg/triggerspawn/PlayerResolver.java b/src/main/java/games/dmg/triggerspawn/PlayerResolver.java new file mode 100644 index 0000000..1582082 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/PlayerResolver.java @@ -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 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(); + } + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java b/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java index 8c0ff52..10efc99 100644 --- a/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java +++ b/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java @@ -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 "); + 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 "); + 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 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 + " "); + sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label + + " "); } } diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index 803c724..9ce70a5 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -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"); diff --git a/src/test/java/games/dmg/triggerspawn/AccessServiceTest.java b/src/test/java/games/dmg/triggerspawn/AccessServiceTest.java new file mode 100644 index 0000000..404e4bd --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/AccessServiceTest.java @@ -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)); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/FriendlyDurationParserTest.java b/src/test/java/games/dmg/triggerspawn/FriendlyDurationParserTest.java new file mode 100644 index 0000000..210a923 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/FriendlyDurationParserTest.java @@ -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")); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java b/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java index 6357a57..4120347 100644 --- a/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java +++ b/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java @@ -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"});