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