3 Commits
Author SHA1 Message Date
dmg 26aeb8c416 feat(border): add base boundary particles
CI / build (push) Successful in 1m1s
Release / release (push) Successful in 2m18s
2026-08-10 22:12:16 -04:00
dmg 4278e08bf3 feat(commands): unify settings and admin commands
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-10 20:44:34 -04:00
dmg 5c9dc5b361 feat(commands): add toggle autocomplete aliases
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 59s
2026-08-10 20:07:07 -04:00
35 changed files with 1539 additions and 522 deletions
+11 -9
View File
@@ -24,28 +24,30 @@ The plugin JAR is written to `build/libs/`.
```text
/setbase (alias: /sethome)
/base (alias: /home)
/base upgrade (alias: /home upgrade)
/basenavigation
/baseflight
/basevisitors
/gotobase <player> (alias: /visit <player>)
/baseprogress
/baseprogress bossbar
/basesettings (alias: /homesettings)
/basesettings status
/basesettings upgrade
/basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable>
/basesettings flight <enable|disable>
/basesettings border <enable|disable>
/basesettings bossbar <enable|disable>
```
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
## Administration
The `spigotbase.admin` permission is granted to server operators by default.
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
```text
/baseadmin progress <player>
/baseadmin status <player>
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>
/baseadmin config <numeric-key> <value>
/baseadmin clearcooldown <player> [personal|visitor|all]
/baseadmin reset <player> <base|size|flight|warmup|cooldown>
/baseadmin reset <player> <size|flight|warmup|cooldown>
/baseadmin reset <player> all confirm
```
+30
View File
@@ -46,3 +46,33 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Added `/sethome` for `/setbase`, `/home` for `/base`, and `/visit` for `/gotobase`.
- Visitor autocomplete remains available for eligible offline bases through the `/visit` alias.
- Verified the aliases and plugin build with `./gradlew clean check jar`.
## 2026-08-10 — Toggle command autocomplete
- Added explicit, idempotent `on` and `off` modes and autocomplete to base flight and visitor access while retaining no-argument toggling.
- Added `/homenavigation`, `/homeflight`, and `/homevisitors` aliases with the same autocomplete as their canonical commands.
- Generalized toggle preference resolution for navigation, flight, and visitor access.
- Verified the implementation with `./gradlew clean check jar`.
## 2026-08-10 — Unified base settings command
- Replaced the separate progress, navigation, flight, and visitor commands with `/basesettings` and its `/homesettings` alias.
- Added nested autocomplete for status, Base IV upgrades, visitor access, navigation, flight, and progress boss-bar settings.
- Moved the Base IV purchase from `/base upgrade` to `/basesettings upgrade`, leaving `/base` and `/home` dedicated to personal teleportation.
- Preserved the full progression report as the default `/basesettings` response and as `/basesettings status`.
- Verified the breaking command restructure with `./gradlew clean check jar`.
## 2026-08-10 — Administrative command autocomplete and reset safety
- Added `/homeadmin` as an alias for `/baseadmin` and replaced the administrative `progress` subcommand with `status`.
- Added permission-aware, context-sensitive autocomplete for subcommands, online and known offline players, paths, levels, counters, cooldown types, reset confirmation, and numeric configuration keys.
- Removed the unsafe unconfirmed base reset path; complete resets now require `reset <player> all confirm`.
- Tightened argument validation and replaced implementation-specific enum errors with player-facing usage guidance.
- Verified the administrative revision with `./gradlew clean check jar`.
## 2026-08-10 — Base border visualization
- Added persisted, owner-only base border particles controlled by `/basesettings border enable|disable` with contextual autocomplete.
- Rendered a bounded nearby arc at the player's current height using the current base radius while respecting world and vertical bounds.
- Added border preference reporting to player and administrative status output and safe defaults for existing state files.
- Verified the feature with `./gradlew clean check jar`.
+1
View File
@@ -18,3 +18,4 @@ description: Catalog of user stories for the Spigot Base plugin.
10. [US-010: Administer player progression](us-010-administer-player-progression.md)
11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
13. [US-013: Visualize the base border](us-013-visualize-base-border.md)
@@ -17,9 +17,8 @@ As a **player with Base I**, I want visual guidance toward my base so that I can
- [x] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base.
- [x] Particle generation is bounded to avoid excessive server or client load.
- [x] A player in another world receives a clear message instead of a misleading particle direction.
- [x] `/basenavigation` toggles guidance on and off after Base II is unlocked.
- [x] `/basenavigation on` enables guidance idempotently and `/basenavigation off` disables it idempotently.
- [x] Invalid navigation arguments show command usage, and `on` and `off` are offered through autocomplete.
- [x] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently.
- [x] Invalid navigation arguments show command usage, and `enable` and `disable` are offered through autocomplete.
- [x] Grass-or-dirt progress after Base II preserves the player's selected navigation preference.
- [x] The navigation preference persists across reconnects and restarts.
@@ -24,7 +24,8 @@ As a **player with Base I**, I want to unlock controlled flight around my base s
- [x] Plugin-granted flight remains active in the warning buffer and displays prominent on-screen notice that the player is leaving the base.
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
- [x] Flight is not granted outside the unlocked vertical range.
- [x] `/baseflight` toggles the player's unlocked base flight on and off.
- [x] `/basesettings flight enable` enables unlocked flight idempotently, and `/basesettings flight disable` disables it idempotently.
- [x] Invalid flight arguments show command usage, and `enable` and `disable` are offered through autocomplete.
- [x] The flight toggle persists across reconnects and restarts.
- [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
@@ -15,7 +15,7 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
- [x] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
- [x] `/home` is an alias for `/base`, including `/home upgrade`.
- [x] `/home` aliases the teleport-only `/base` command.
- [x] Looking around without changing block coordinates does not cancel the warm-up.
- [x] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up.
- [x] Cancellation clearly informs the player and does not consume the cooldown.
@@ -12,10 +12,11 @@ As a **player with Base III**, I want to open my base to visitors so that other
## Acceptance criteria
- [x] Base IV requires Base III and an established base.
- [x] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds.
- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
- [x] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
- [x] `/basevisitors` lets a Base IV owner toggle visitor access on and off.
- [x] `/basesettings visitors allowed` permits visitor access idempotently for a Base IV owner, and `/basesettings visitors blocked` blocks it idempotently.
- [x] Invalid visitor arguments show command usage, and `allowed` and `blocked` are offered through autocomplete.
- [x] The visitor-access preference persists across reconnects and restarts.
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
@@ -11,17 +11,20 @@ As a **player**, I want to inspect my progression and receive timely milestone f
## Acceptance criteria
- [x] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths.
- [x] `/basesettings` and `/basesettings status` show the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
- [x] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent.
- [x] The automatic boss bar disappears after a configurable number of seconds.
- [x] `/baseprogress bossbar` toggles automatic progress boss bars on and off.
- [x] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress.
- [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently.
- [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete.
- [x] Disabling automatic boss bars does not prevent `/basesettings` from displaying progress.
- [x] The boss-bar preference persists across reconnects and restarts.
- [x] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
- [x] `/homesettings` aliases `/basesettings` with identical behavior and autocomplete.
- [x] Superseded `/baseprogress`, `/basenavigation`, `/baseflight`, and `/basevisitors` commands and their aliases are no longer registered.
## Related
@@ -14,16 +14,20 @@ As a **server administrator**, I want command-based progression controls so that
- [x] Administrative commands require `spigotbase.admin`, which server operators receive by default.
- [x] Administrative player arguments safely resolve online players and previously known offline players.
- [x] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
- [x] `/baseadmin progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [x] `/baseadmin status <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
- [x] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
- [x] `/baseadmin reset <player> <size|flight|warmup|cooldown>` resets a selected path without silently leaving benefits that require it.
- [x] `/baseadmin reset <player> base` is rejected and directs the administrator to the confirmed complete-reset command.
- [x] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
- [x] Administrators can clear personal and visitor cooldowns independently.
- [x] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values.
- [x] Runtime configuration changes are persisted for subsequent restarts.
- [x] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player.
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
- [x] `/homeadmin` aliases `/baseadmin` with identical permission requirements and autocomplete.
- [x] Permission-aware autocomplete offers subcommands, online and known offline players, progression paths, valid levels, progress counters, cooldown types, reset paths and confirmation, and numeric configuration keys.
- [x] Invalid and extra arguments show friendly usage without exposing implementation-specific enum errors or modifying state.
## Related
@@ -0,0 +1,31 @@
---
type: User Story
title: "US-013: Visualize the base border"
description: Let players display a bounded, owner-only particle arc along the current edge of their established base.
status: done
---
# US-013: Visualize the base border
As a **player with Base I**, I want to display a particle effect at my base border so that I can understand where base benefits begin and end while building.
## Acceptance criteria
- [x] Border visualization requires Base I and an established base.
- [x] `/basesettings border enable` enables visualization idempotently, and `/basesettings border disable` disables it idempotently.
- [x] `border`, `enable`, and `disable` are offered through contextual autocomplete.
- [x] Border visualization is disabled by default and the preference persists across reconnects and restarts.
- [x] Older state files without the preference load it as disabled.
- [x] `/basesettings status` and `/baseadmin status` display the border preference.
- [x] Particles are visible only to the base owner.
- [x] Particles trace the current circular horizontal boundary at the player's current Y-level and follow base expansion or relocation immediately.
- [x] Particles render only while the player is in the base world, within the base's vertical range, and reasonably close to the boundary.
- [x] Particle generation is bounded to avoid excessive server and client load.
- [x] A complete administrative reset disables border visualization.
## Related
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
- [US-003: Expand the base](us-003-expand-the-base.md)
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md)
@@ -1,17 +1,35 @@
package games.dmg.spigotbase;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.TreeSet;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
final class BaseAdminCommand implements CommandExecutor {
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"status", "setlevel", "setprogress", "clearcooldown", "reset", "config"
);
private static final List<String> PATHS = List.of(
"base", "size", "flight", "warmup", "cooldown"
);
private static final List<String> COUNTERS = List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"
);
private static final List<String> COOLDOWN_TYPES = List.of("personal", "visitor", "all");
private static final List<String> RESET_PATHS = List.of(
"size", "flight", "warmup", "cooldown", "all"
);
private static final List<String> LEVELS_THREE = List.of("0", "1", "2", "3");
private static final List<String> LEVELS_FOUR = List.of("0", "1", "2", "3", "4");
private final JavaPlugin plugin;
private final BaseStateManager stateManager;
private final AdminProgressionService progressionService;
@@ -48,7 +66,7 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "progress" -> showProgress(sender, target.orElseThrow());
case "status" -> showProgress(sender, target.orElseThrow(), arguments);
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
@@ -60,7 +78,15 @@ final class BaseAdminCommand implements CommandExecutor {
};
}
private boolean showProgress(CommandSender sender, PlayerState player) {
private boolean showProgress(
CommandSender sender,
PlayerState player,
String[] arguments
) {
if (arguments.length != 2) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin status <player>");
return true;
}
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
@@ -71,8 +97,8 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
+ " breaks=" + player.blocksBrokenInBase());
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
+ " flight=" + player.flightEnabled() + " bossbar=" + player.bossBarEnabled()
+ " visitors=" + player.visitorsEnabled());
+ " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
+ " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
.orElse("not set"));
@@ -89,8 +115,16 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
return true;
}
final ProgressionPath path;
try {
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setlevel <player> "
+ "<base|size|flight|warmup|cooldown> <level>");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
int level = Integer.parseInt(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
@@ -100,6 +134,8 @@ final class BaseAdminCommand implements CommandExecutor {
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "The level must be an integer.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
@@ -116,10 +152,18 @@ final class BaseAdminCommand implements CommandExecutor {
+ "Usage: /baseadmin setprogress <player> <counter> <amount>");
return true;
}
final ProgressCounter counter;
try {
ProgressCounter counter = ProgressCounter.valueOf(
counter = ProgressCounter.valueOf(
arguments[2].toUpperCase(Locale.ROOT).replace('-', '_')
);
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setprogress <player> "
+ "<grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>");
return true;
}
try {
long amount = Long.parseLong(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
@@ -142,7 +186,12 @@ final class BaseAdminCommand implements CommandExecutor {
PlayerState target,
String[] arguments
) {
String selection = arguments.length >= 3
if (arguments.length < 2 || arguments.length > 3) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin clearcooldown <player> [personal|visitor|all]");
return true;
}
String selection = arguments.length == 3
? arguments[2].toLowerCase(Locale.ROOT)
: "all";
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
@@ -186,8 +235,26 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
return true;
}
if (arguments.length != 3) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
return true;
}
if (arguments[2].equalsIgnoreCase("base")) {
sender.sendMessage(ChatColor.RED
+ "Resetting base removes all progression. Use: /baseadmin reset "
+ target.latestName() + " all confirm");
return true;
}
final ProgressionPath path;
try {
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
stateManager.update(
target.playerId(),
target.latestName(),
@@ -239,6 +306,76 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!sender.hasPermission("spigotbase.admin")) {
return List.of();
}
if (arguments.length == 1) {
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return SUBCOMMANDS.stream()
.filter(subcommand -> subcommand.startsWith(prefix))
.toList();
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("config")) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return plugin.getConfig().getValues(false).entrySet().stream()
.filter(entry -> entry.getValue() instanceof Number)
.map(java.util.Map.Entry::getKey)
.filter(key -> key.toLowerCase(Locale.ROOT).startsWith(prefix))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
if (arguments.length == 2) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
TreeSet<String> names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
stateManager.knownPlayers().values().stream()
.map(PlayerState::latestName)
.forEach(names::add);
if (plugin.getServer() != null) {
plugin.getServer().getOnlinePlayers().stream()
.map(Player::getName)
.forEach(names::add);
}
return names.stream()
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
.toList();
}
if (arguments.length == 3) {
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "setlevel" -> PATHS;
case "setprogress" -> COUNTERS;
case "clearcooldown" -> COOLDOWN_TYPES;
case "reset" -> RESET_PATHS;
default -> List.of();
};
return complete(options, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) {
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
case "base", "cooldown" -> LEVELS_FOUR;
case "size", "flight", "warmup" -> LEVELS_THREE;
default -> List.of();
};
return complete(levels, arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("reset")
&& arguments[2].equalsIgnoreCase("all")) {
return complete(List.of("confirm"), arguments[3]);
}
return List.of();
}
private static List<String> complete(List<String> options, String input) {
String prefix = input.toLowerCase(Locale.ROOT);
return options.stream().filter(option -> option.startsWith(prefix)).toList();
}
private Optional<PlayerState> resolve(String name) {
Player online = Bukkit.getPlayerExact(name);
if (online != null) {
@@ -249,7 +386,7 @@ final class BaseAdminCommand implements CommandExecutor {
private static void sendUsage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
+ "<progress|setlevel|setprogress|clearcooldown|reset> <player> ...");
+ "<status|setlevel|setprogress|clearcooldown|reset> <player> ...");
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigotbase;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.entity.Player;
final class BaseBorderController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final BaseBoundsService boundsService;
BaseBorderController(
Server server,
BaseStateManager stateManager,
BaseBoundsService boundsService
) {
this.server = server;
this.stateManager = stateManager;
this.boundsService = boundsService;
}
@Override
public void run() {
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!state.borderEnabled() || state.base().isEmpty()) {
continue;
}
BaseLocation base = state.base().orElseThrow();
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location playerLocation = player.getLocation();
long minimumY = (long) base.y() - boundsService.verticalRange(state);
long maximumY = (long) base.y() + boundsService.verticalRange(state);
if (playerLocation.getY() < minimumY || playerLocation.getY() > maximumY) {
continue;
}
for (BaseBorderGeometry.Point point : BaseBorderGeometry.visiblePoints(
base.x() + 0.5,
base.z() + 0.5,
boundsService.radius(state),
playerLocation.getX(),
playerLocation.getZ()
)) {
Location particle = new Location(
player.getWorld(),
point.x(),
playerLocation.getY() + 0.15,
point.z()
);
player.spawnParticle(
Particle.END_ROD,
particle,
1,
0.0, 0.0, 0.0, 0.0
);
}
}
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.List;
final class BaseBorderGeometry {
static final int MAX_PARTICLES = 64;
static final double VISIBILITY_DISTANCE = 32.0;
private static final double PARTICLE_SPACING = 1.0;
private BaseBorderGeometry() {
}
static List<Point> visiblePoints(
double centerX,
double centerZ,
int radius,
double playerX,
double playerZ
) {
if (radius <= 0) {
throw new IllegalArgumentException("border radius must be positive");
}
double playerRadius = Math.hypot(playerX - centerX, playerZ - centerZ);
if (Math.abs(playerRadius - radius) > VISIBILITY_DISTANCE) {
return List.of();
}
double circumference = 2.0 * Math.PI * radius;
int count = Math.min(MAX_PARTICLES, Math.max(1, (int) Math.ceil(
circumference / PARTICLE_SPACING
)));
double centerAngle = Math.atan2(playerZ - centerZ, playerX - centerX);
double angleStep = circumference <= MAX_PARTICLES * PARTICLE_SPACING
? 2.0 * Math.PI / count
: PARTICLE_SPACING / radius;
double startAngle = circumference <= MAX_PARTICLES * PARTICLE_SPACING
? 0.0
: centerAngle - angleStep * (count - 1) / 2.0;
List<Point> points = new ArrayList<>(count);
double visibilitySquared = VISIBILITY_DISTANCE * VISIBILITY_DISTANCE;
for (int index = 0; index < count; index++) {
double angle = startAngle + angleStep * index;
double x = centerX + radius * Math.cos(angle);
double z = centerZ + radius * Math.sin(angle);
double deltaX = x - playerX;
double deltaZ = z - playerZ;
if (deltaX * deltaX + deltaZ * deltaZ <= visibilitySquared) {
points.add(new Point(x, z));
}
}
return List.copyOf(points);
}
record Point(double x, double z) {
}
}
@@ -1,31 +1,16 @@
package games.dmg.spigotbase;
import java.io.IOException;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseCommand implements CommandExecutor {
private final BaseTeleportManager teleportManager;
private final BaseStateManager stateManager;
private final VisitorPolicy visitorPolicy;
private final PluginSettingsProvider settings;
BaseCommand(
BaseTeleportManager teleportManager,
BaseStateManager stateManager,
VisitorPolicy visitorPolicy,
PluginSettingsProvider settings
) {
BaseCommand(BaseTeleportManager teleportManager) {
this.teleportManager = teleportManager;
this.stateManager = stateManager;
this.visitorPolicy = visitorPolicy;
this.settings = settings;
}
@Override
@@ -38,95 +23,7 @@ final class BaseCommand implements CommandExecutor {
teleportManager.start(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
purchaseVisitorAccess(player);
return true;
}
player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]");
player.sendMessage(ChatColor.RED + "Usage: /base");
return true;
}
private void purchaseVisitorAccess(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!visitorPolicy.canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return;
}
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored.");
return;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
}
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeCurrency(
PlayerInventory inventory,
Material currency,
int amount
) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
}
@@ -1,43 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseFlightCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final BaseFlightController controller;
BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) {
this.stateManager = stateManager;
this.controller = controller;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base flight.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(!current.flightEnabled())
);
if (!state.flightEnabled()) {
controller.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
}
@@ -80,7 +80,7 @@ final class BaseFlightController implements Runnable {
stateManager.saveIfDirty();
player.sendTitle(
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
ChatColor.YELLOW + "Use /baseflight to toggle flight",
ChatColor.YELLOW + "Use /basesettings flight enable to enable flight",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
@@ -1,73 +0,0 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
final class BaseNavigationCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager;
BaseNavigationCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base navigation.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
final boolean enabled;
try {
enabled = NavigationPreference.resolve(state.navigationEnabled(), arguments);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length != 1) {
return List.of();
}
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
}
@@ -1,121 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseProgressCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
BaseProgressCommand(BaseStateManager stateManager, PluginSettingsProvider settings) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) {
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(!current.bossBarEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state);
showSizePath(player, state);
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
return true;
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.current().visitorUnlockDiamondCost() + " diamonds → visitor access";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; toggle=" + (state.flightEnabled() ? "on" : "off"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
}
@@ -149,7 +149,7 @@ final class BaseProgressListener implements Listener {
int level = update.player().baseLevel();
String subtitle = switch (level) {
case 1 -> "/setbase is now available";
case 2 -> "/basenavigation is now available";
case 2 -> "/basesettings navigation enable is now available";
case 3 -> "/base is now available";
case 4 -> "Visitors can now travel to your base";
default -> "A new base benefit is available";
@@ -0,0 +1,404 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of(
"status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"
);
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController;
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController
) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 0
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
showStatus(player, state);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
return purchaseVisitorAccess(player, state);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("navigation")) {
return updateNavigation(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) {
return updateFlight(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
return updateBorder(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
return updateBossBar(player, arguments[1]);
}
sendUsage(player);
return true;
}
private boolean purchaseVisitorAccess(Player player, PlayerState state) {
if (!new VisitorPolicy().canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return true;
}
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The upgrade could not be saved; your diamonds were restored.");
return true;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
private boolean updateVisitors(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true;
}
final boolean enabled;
if (mode.equalsIgnoreCase("allowed")) {
enabled = true;
} else if (mode.equalsIgnoreCase("blocked")) {
enabled = false;
} else {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "allowed" : ChatColor.RED + "blocked")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateNavigation(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
private boolean updateFlight(Player player, PlayerState state, String mode) {
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(enabled)
);
if (!state.flightEnabled()) {
flightController.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBorder(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 1 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base I before enabling border visualization.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBorderEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base border visualization is now "
+ (state.borderEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBossBar(Player player, String mode) {
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
PlayerState state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private void showStatus(Player player, PlayerState state) {
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state);
showSizePath(player, state);
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
player.sendMessage(ChatColor.GRAY + "Settings: visitors="
+ (state.visitorsEnabled() ? "allowed" : "blocked")
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.current().visitorUnlockDiamondCost()
+ " diamonds → /basesettings upgrade";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; setting=" + (state.flightEnabled() ? "enabled" : "disabled"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length == 1) {
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return SETTINGS.stream().filter(setting -> setting.startsWith(prefix)).toList();
}
if (arguments.length == 2) {
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "navigation", "flight", "border", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
return List.of();
}
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeCurrency(PlayerInventory inventory, Material currency, int amount) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
private static Boolean enabledMode(String mode) {
if (mode.equalsIgnoreCase("enable")) {
return true;
}
if (mode.equalsIgnoreCase("disable")) {
return false;
}
return null;
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>|bossbar <enable|disable>]");
}
}
@@ -1,38 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseVisitorsCommand implements CommandExecutor {
private final BaseStateManager stateManager;
BaseVisitorsCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can manage base visitors.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(!current.visitorsEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
}
@@ -1,22 +0,0 @@
package games.dmg.spigotbase;
import java.util.Locale;
final class NavigationPreference {
private NavigationPreference() {
}
static boolean resolve(boolean current, String[] arguments) {
if (arguments.length == 0) {
return !current;
}
if (arguments.length != 1) {
throw new IllegalArgumentException("expected zero or one argument");
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "on" -> true;
case "off" -> false;
default -> throw new IllegalArgumentException("expected on or off");
};
}
}
@@ -26,7 +26,8 @@ public record PlayerState(
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled
) {
public PlayerState {
if (playerId == null) {
@@ -69,12 +70,47 @@ public record PlayerState(
if (baseLevel < 4 && visitorsEnabled) {
throw new IllegalArgumentException("visitor access requires Base IV");
}
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("border visualization requires an established Base I");
}
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || entry.getValue() == null)) {
throw new IllegalArgumentException("visitor cooldowns must be complete");
}
}
public PlayerState(
UUID playerId,
String latestName,
Optional<BaseLocation> base,
int baseLevel,
int sizeLevel,
int flightLevel,
int warmupLevel,
int cooldownLevel,
long grassAndDirtBroken,
long stoneBroken,
long deepslateBroken,
long obsidianBroken,
long blocksPlacedInBase,
long blocksBrokenInBase,
boolean navigationEnabled,
boolean flightEnabled,
boolean bossBarEnabled,
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil
) {
this(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, false
);
}
public static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState(
playerId,
@@ -93,7 +129,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -103,7 +139,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -113,7 +149,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -123,7 +159,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -133,7 +169,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
obsidian, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -143,7 +179,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -163,7 +199,7 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, placements, breaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -179,7 +215,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled
);
}
@@ -189,7 +225,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -206,7 +242,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
obsidian, placements, baseBreaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -225,7 +261,8 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil,
borderEnabled && newBaseLevel >= 1 && base.isPresent()
);
}
@@ -235,7 +272,17 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
public PlayerState withBorderEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, enabled
);
}
@@ -247,7 +294,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, cooldowns
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled
);
}
@@ -257,7 +304,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.empty(), visitorCooldownUntil
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled
);
}
@@ -267,7 +314,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, Map.of()
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled
);
}
@@ -277,7 +324,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled
);
}
@@ -65,28 +65,25 @@ public final class SpigotBasePlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(teleportManager, this);
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
command("base").setExecutor(
new BaseCommand(teleportManager, stateManager, visitorPolicy, settingsProvider)
command("base").setExecutor(new BaseCommand(teleportManager));
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
stateManager,
settingsProvider,
flightController
);
command("baseprogress").setExecutor(
new BaseProgressCommand(stateManager, settingsProvider)
);
BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager);
command("basenavigation").setExecutor(navigationCommand);
command("basenavigation").setTabCompleter(navigationCommand);
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController));
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager));
command("basesettings").setExecutor(settingsCommand);
command("basesettings").setTabCompleter(settingsCommand);
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
command("gotobase").setExecutor(goToBaseCommand);
command("gotobase").setTabCompleter(goToBaseCommand);
command("baseadmin").setExecutor(
new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
)
BaseAdminCommand adminCommand = new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
);
command("baseadmin").setExecutor(adminCommand);
command("baseadmin").setTabCompleter(adminCommand);
getServer().getScheduler().runTaskTimer(
this,
@@ -94,6 +91,12 @@ public final class SpigotBasePlugin extends JavaPlugin {
10L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new BaseBorderController(getServer(), stateManager, boundsService),
10L,
10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Base enabled.");
@@ -127,7 +127,8 @@ public final class YamlBaseStateRepository {
yaml.getBoolean(path + ".visitors-enabled", false),
instant(yaml, path + ".last-base-set-epoch-millis"),
instant(yaml, path + ".last-base-teleport-epoch-millis"),
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns")
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns"),
yaml.getBoolean(path + ".border-enabled", false)
);
players.put(playerId, player);
} catch (IllegalArgumentException ignored) {
@@ -222,6 +223,7 @@ public final class YamlBaseStateRepository {
yaml.set(path + ".flight-enabled", player.flightEnabled());
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
yaml.set(path + ".border-enabled", player.borderEnabled());
yaml.set(
path + ".last-base-set-epoch-millis",
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
+7 -14
View File
@@ -10,28 +10,21 @@ commands:
usage: /setbase
aliases: [sethome]
base:
description: Teleport to or upgrade your base.
usage: /base [upgrade]
description: Teleport to your base.
usage: /base
aliases: [home]
basenavigation:
description: Toggle particle navigation toward your base.
usage: /basenavigation [on|off]
baseflight:
description: Toggle flight within your base.
usage: /baseflight
basevisitors:
description: Toggle visitor access to your base.
usage: /basevisitors
gotobase:
description: Visit an available player base.
usage: /gotobase <player>
aliases: [visit]
baseprogress:
description: View progression or toggle progress boss bars.
usage: /baseprogress [bossbar]
basesettings:
description: View progression, upgrade, and manage base settings.
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|bossbar]
aliases: [homesettings]
baseadmin:
description: Administer Spigot Base.
usage: /baseadmin <subcommand>
aliases: [homeadmin]
permission: spigotbase.admin
permissions:
spigotbase.admin:
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -36,6 +37,22 @@ final class AdminProgressionServiceTest {
assertFalse(updated.visitorsEnabled());
}
@Test
void loweringBaseDisablesBorderVisualization() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withBorderEnabled(true);
PlayerState updated = service.setLevel(player, ProgressionPath.BASE, 0);
assertFalse(updated.borderEnabled());
}
@Test
void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -0,0 +1,227 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
final class BaseAdminCommandTest {
@Test
void invalidPathShowsFriendlyUsage() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "unknown", "1"});
}
verify(sender).sendMessage(ChatColor.RED
+ "Usage: /baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>");
verify(stateManager, never()).update(any(), anyString(), any());
}
@Test
void autocompletesValidLevelsAndResetConfirmation() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("0", "1", "2", "3", "4"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "base", ""})
);
assertEquals(
List.of("0", "1", "2", "3"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "flight", ""})
);
assertEquals(
List.of("confirm"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"reset", "Builder", "all", ""})
);
}
@Test
void autocompletesCommandSpecificOptions() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("base", "size", "flight", "warmup", "cooldown"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", ""})
);
assertEquals(
List.of("grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setprogress", "Builder", ""})
);
assertEquals(
List.of("personal", "visitor", "all"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"clearcooldown", "Builder", ""})
);
assertEquals(
List.of("size", "flight", "warmup", "cooldown", "all"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"reset", "Builder", ""})
);
}
@Test
void autocompletesNumericConfigurationKeys() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.getValues(false)).thenReturn(Map.of(
"base-unlock-blocks", 250,
"visitor-currency-material", "DIAMOND",
"title-stay-ticks", 70
));
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("base-unlock-blocks"),
command.onTabComplete(sender, null, "baseadmin", new String[] {"config", "base"})
);
}
@Test
void autocompletesKnownOfflinePlayers() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseStateManager stateManager = mock(BaseStateManager.class);
PlayerState alex = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState blake = PlayerState.newPlayer(UUID.randomUUID(), "Blake");
when(stateManager.knownPlayers()).thenReturn(Map.of(
alex.playerId(), alex,
blake.playerId(), blake
));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("Alex", "Blake"),
command.onTabComplete(sender, null, "baseadmin", new String[] {"status", ""})
);
}
@Test
void autocompletesAdminSubcommandsForPermittedSenders() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("status", "setlevel", "setprogress", "clearcooldown", "reset", "config"),
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
);
}
@Test
void resetBaseRequiresConfirmedCompleteReset() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(
sender,
null,
"baseadmin",
new String[] {"reset", "Builder", "base"}
);
}
verify(stateManager, never()).update(any(), anyString(), any());
verify(sender).sendMessage(ChatColor.RED
+ "Resetting base removes all progression. Use: /baseadmin reset Builder all confirm");
}
@Test
void statusDisplaysKnownPlayerProgress() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin", new String[] {"status", "Builder"});
}
verify(sender).sendMessage(ChatColor.GOLD + "=== Builder Base Progress ===");
}
}
@@ -0,0 +1,92 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseBorderControllerTest {
@Test
void doesNotRenderOutsideTheBaseVerticalRange() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withBorderEnabled(true);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 10.5, 100.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
new BaseBorderController(
server,
stateManager,
new BaseBoundsService(PluginSettings.from(Map.of()))
).run();
verify(player, never()).spawnParticle(
eq(Particle.END_ROD),
any(Location.class),
eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
@Test
void ownerSeesParticlesAtCurrentHeightNearEnabledBorder() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withBorderEnabled(true);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 10.5, 70.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
new BaseBorderController(
server,
stateManager,
new BaseBoundsService(PluginSettings.from(Map.of()))
).run();
verify(player, atLeastOnce()).spawnParticle(
eq(Particle.END_ROD),
any(Location.class),
eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
final class BaseBorderGeometryTest {
@Test
void producesBoundedPointsOnTheCurrentCircularBoundary() {
List<BaseBorderGeometry.Point> points = BaseBorderGeometry.visiblePoints(
0.5, 0.5, 150, 150.5, 0.5
);
assertFalse(points.isEmpty());
assertTrue(points.size() <= BaseBorderGeometry.MAX_PARTICLES);
assertTrue(points.stream().allMatch(point ->
Math.abs(Math.hypot(point.x() - 0.5, point.z() - 0.5) - 150.0) < 0.0001
));
}
@Test
void omitsParticlesWhenPlayerIsFarFromBoundary() {
assertTrue(BaseBorderGeometry.visiblePoints(0.5, 0.5, 10, 100.5, 0.5).isEmpty());
}
}
@@ -0,0 +1,246 @@
package games.dmg.spigotbase;
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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
final class BaseSettingsCommandTest {
@Test
void borderEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
CommandResult result = execute(current, "border", "enable");
assertTrue(result.updated().borderEnabled());
}
@Test
void invalidArgumentsShowUsage() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[] {"unknown"});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Usage: /basesettings")
));
}
@Test
void autocompletesValuesForEachSetting() {
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
assertEquals(
List.of("allowed"),
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
);
for (String setting : List.of("navigation", "flight", "border", "bossbar")) {
assertEquals(
List.of("disable"),
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
);
}
}
@Test
void autocompletesSettingNames() {
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
assertEquals(
List.of("status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"),
command.onTabComplete(null, null, "basesettings", new String[] {""})
);
}
@Test
void upgradePurchasesBaseIv() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Host");
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
new ItemStack(Material.DIAMOND, 64),
new ItemStack(Material.DIAMOND, 64)
});
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Host")).thenReturn(current);
when(stateManager.updateAndSave(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[] {"upgrade"});
assertEquals(4, updated.get().baseLevel());
assertTrue(updated.get().visitorsEnabled());
}
@Test
void bossbarEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Miner");
CommandResult result = execute(current, "bossbar", "enable");
assertTrue(result.updated().bossBarEnabled());
}
@Test
void flightDisableIsIdempotentAndRemovesGrantedFlight() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Pilot")
.withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false);
CommandResult result = execute(current, "flight", "disable");
assertFalse(result.updated().flightEnabled());
verify(result.flightController()).removeGrantedFlight(any(Player.class));
}
@Test
void navigationDisableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Explorer")
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
CommandResult result = execute(current, "navigation", "disable");
assertFalse(result.updated().navigationEnabled());
}
@Test
void visitorsAllowedIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true);
CommandResult result = execute(current, "visitors", "allowed");
assertTrue(result.updated().visitorsEnabled());
}
@Test
void defaultAndStatusDisplayTheFullProgressReport() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[0]);
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player, times(2)).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Settings: visitors=")
&& message.contains("navigation=")
&& message.contains("flight=")
&& message.contains("border=")
&& message.contains("bossbar=")
)
);
}
private static CommandResult execute(PlayerState current, String... arguments) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(current.playerId());
when(player.getName()).thenReturn(current.latestName());
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(current.playerId(), current.latestName())).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseFlightController flightController = mock(BaseFlightController.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
flightController
);
command.onCommand(player, null, "basesettings", arguments);
return new CommandResult(updated.get(), flightController);
}
private record CommandResult(PlayerState updated, BaseFlightController flightController) {
}
}
@@ -1,35 +0,0 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
final class NavigationPreferenceTest {
@Test
void noArgumentTogglesCurrentPreference() {
assertFalse(NavigationPreference.resolve(true, new String[0]));
assertTrue(NavigationPreference.resolve(false, new String[0]));
}
@Test
void explicitModesAreIdempotent() {
assertTrue(NavigationPreference.resolve(true, new String[] {"on"}));
assertTrue(NavigationPreference.resolve(false, new String[] {"on"}));
assertFalse(NavigationPreference.resolve(true, new String[] {"off"}));
assertFalse(NavigationPreference.resolve(false, new String[] {"off"}));
}
@Test
void rejectsUnknownOrExtraArguments() {
assertThrows(
IllegalArgumentException.class,
() -> NavigationPreference.resolve(false, new String[] {"maybe"})
);
assertThrows(
IllegalArgumentException.class,
() -> NavigationPreference.resolve(false, new String[] {"on", "off"})
);
}
}
@@ -1,13 +1,31 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStateTest {
@Test
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex");
PlayerState established = player
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
assertFalse(player.borderEnabled());
assertTrue(established.withBorderEnabled(true).borderEnabled());
}
@Test
void rejectsLevelsOutsideKnownRanges() {
UUID playerId = UUID.randomUUID();
@@ -1,6 +1,7 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream;
@@ -10,6 +11,34 @@ import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class PluginMetadataTest {
@Test
void homeadminAliasesBaseadmin() {
Map<?, ?> commands = commands();
Map<?, ?> admin = (Map<?, ?>) commands.get("baseadmin");
assertEquals(List.of("homeadmin"), admin.get("aliases"));
}
@Test
void doesNotDeclareSupersededSettingsCommands() {
Map<?, ?> commands = commands();
for (String command : List.of(
"baseprogress", "basenavigation", "baseflight", "basevisitors"
)) {
assertFalse(commands.containsKey(command), command);
}
}
@Test
void homesettingsAliasesBasesettings() {
Map<?, ?> commands = commands();
Map<?, ?> settings = (Map<?, ?>) commands.get("basesettings");
assertNotNull(settings);
assertEquals(List.of("homesettings"), settings.get("aliases"));
}
@Test
void sethomeAliasesSetbase() {
Map<?, ?> commands = commands();
@@ -46,8 +75,7 @@ final class PluginMetadataTest {
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
for (String command : new String[] {
"setbase", "base", "basenavigation", "baseflight",
"basevisitors", "gotobase", "baseprogress", "baseadmin"
"setbase", "base", "gotobase", "basesettings", "baseadmin"
}) {
assertNotNull(commands.get(command), command);
}
@@ -28,7 +28,8 @@ final class YamlBaseStateRepositoryTest {
true, true, false, true,
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L))
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L)),
true
);
PersistentState expected = new PersistentState(Map.of(playerId, player));
YamlBaseStateRepository repository =
@@ -39,6 +40,24 @@ final class YamlBaseStateRepositoryTest {
assertEquals(expected, repository.load());
}
@Test
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
""".formatted(playerId));
PlayerState player = new YamlBaseStateRepository(stateFile)
.load()
.players()
.get(playerId);
assertFalse(player.borderEnabled());
}
@Test
void invalidRecordsCannotGrantProgression() throws Exception {
UUID playerId = UUID.randomUUID();