feat(border): add base boundary particles
Release / release (push) Successful in 2m18s
CI / build (push) Successful in 1m1s

This commit is contained in:
dmg
2026-08-10 22:12:16 -04:00
parent 4278e08bf3
commit 26aeb8c416
18 changed files with 458 additions and 27 deletions
+1
View File
@@ -31,6 +31,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings visitors <allowed|blocked> /basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable> /basesettings navigation <enable|disable>
/basesettings flight <enable|disable> /basesettings flight <enable|disable>
/basesettings border <enable|disable>
/basesettings bossbar <enable|disable> /basesettings bossbar <enable|disable>
``` ```
+7
View File
@@ -69,3 +69,10 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Removed the unsafe unconfirmed base reset path; complete resets now require `reset <player> all confirm`. - 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. - Tightened argument validation and replaced implementation-specific enum errors with player-facing usage guidance.
- Verified the administrative revision with `./gradlew clean check jar`. - 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) 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) 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) 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)
@@ -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)
@@ -97,8 +97,8 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase() sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
+ " breaks=" + player.blocksBrokenInBase()); + " breaks=" + player.blocksBrokenInBase());
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled() sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
+ " flight=" + player.flightEnabled() + " bossbar=" + player.bossBarEnabled() + " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
+ " visitors=" + player.visitorsEnabled()); + " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base() sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z()) .map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
.orElse("not set")); .orElse("not set"));
@@ -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) {
}
}
@@ -15,7 +15,7 @@ import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter { final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of( private static final List<String> SETTINGS = List.of(
"status", "upgrade", "visitors", "navigation", "flight", "bossbar" "status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"
); );
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked"); private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
private static final List<String> ENABLE_MODES = List.of("enable", "disable"); private static final List<String> ENABLE_MODES = List.of("enable", "disable");
@@ -59,6 +59,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) { if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) {
return updateFlight(player, state, arguments[1]); 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")) { if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
return updateBossBar(player, arguments[1]); return updateBossBar(player, arguments[1]);
} }
@@ -189,6 +192,29 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true; 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) { private boolean updateBossBar(Player player, String mode) {
Boolean enabled = enabledMode(mode); Boolean enabled = enabledMode(mode);
if (enabled == null) { if (enabled == null) {
@@ -218,6 +244,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ (state.visitorsEnabled() ? "allowed" : "blocked") + (state.visitorsEnabled() ? "allowed" : "blocked")
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled") + " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled") + " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled")); + " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
state.base().ifPresentOrElse( state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " " base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
@@ -306,7 +333,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors") List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES ? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) { : switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "navigation", "flight", "bossbar" -> ENABLE_MODES; case "navigation", "flight", "border", "bossbar" -> ENABLE_MODES;
default -> List.of(); default -> List.of();
}; };
String prefix = arguments[1].toLowerCase(Locale.ROOT); String prefix = arguments[1].toLowerCase(Locale.ROOT);
@@ -372,6 +399,6 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) { private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings " player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>" + "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|bossbar <enable|disable>]"); + "|flight <enable|disable>|border <enable|disable>|bossbar <enable|disable>]");
} }
} }
@@ -26,7 +26,8 @@ public record PlayerState(
boolean visitorsEnabled, boolean visitorsEnabled,
Optional<Instant> lastBaseSet, Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport, Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled
) { ) {
public PlayerState { public PlayerState {
if (playerId == null) { if (playerId == null) {
@@ -69,12 +70,47 @@ public record PlayerState(
if (baseLevel < 4 && visitorsEnabled) { if (baseLevel < 4 && visitorsEnabled) {
throw new IllegalArgumentException("visitor access requires Base IV"); 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 -> if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || entry.getValue() == null)) { entry.getKey() == null || entry.getValue() == null)) {
throw new IllegalArgumentException("visitor cooldowns must be complete"); 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) { public static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState( return new PlayerState(
playerId, playerId,
@@ -93,7 +129,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -103,7 +139,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -113,7 +149,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, enabled, visitorsEnabled, navigationEnabled, flightEnabled, enabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -123,7 +159,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
enabled, flightEnabled, bossBarEnabled, visitorsEnabled, enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -133,7 +169,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate, warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
obsidian, blocksPlacedInBase, blocksBrokenInBase, obsidian, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -143,7 +179,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled, navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -163,7 +199,7 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, placements, breaks, obsidianBroken, placements, breaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -179,7 +215,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, 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, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -206,7 +242,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassDirt, stone, deepslate, warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
obsidian, placements, baseBreaks, obsidian, placements, baseBreaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -225,7 +261,8 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled, 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, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, enabled, 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, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, cooldowns lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled
); );
} }
@@ -257,7 +304,7 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, 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, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, 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, warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase, obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled, navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled
); );
} }
@@ -91,6 +91,12 @@ public final class SpigotBasePlugin extends JavaPlugin {
10L, 10L,
10L 10L
); );
getServer().getScheduler().runTaskTimer(
this,
new BaseBorderController(getServer(), stateManager, boundsService),
10L,
10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L); getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L); getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Base enabled."); getLogger().info("Spigot Base enabled.");
@@ -127,7 +127,8 @@ public final class YamlBaseStateRepository {
yaml.getBoolean(path + ".visitors-enabled", false), yaml.getBoolean(path + ".visitors-enabled", false),
instant(yaml, path + ".last-base-set-epoch-millis"), instant(yaml, path + ".last-base-set-epoch-millis"),
instant(yaml, path + ".last-base-teleport-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); players.put(playerId, player);
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
@@ -222,6 +223,7 @@ public final class YamlBaseStateRepository {
yaml.set(path + ".flight-enabled", player.flightEnabled()); yaml.set(path + ".flight-enabled", player.flightEnabled());
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled()); yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
yaml.set(path + ".visitors-enabled", player.visitorsEnabled()); yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
yaml.set(path + ".border-enabled", player.borderEnabled());
yaml.set( yaml.set(
path + ".last-base-set-epoch-millis", path + ".last-base-set-epoch-millis",
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null) player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
+1 -1
View File
@@ -19,7 +19,7 @@ commands:
aliases: [visit] aliases: [visit]
basesettings: basesettings:
description: View progression, upgrade, and manage base settings. description: View progression, upgrade, and manage base settings.
usage: /basesettings [status|upgrade|visitors|navigation|flight|bossbar] usage: /basesettings [status|upgrade|visitors|navigation|flight|border|bossbar]
aliases: [homesettings] aliases: [homesettings]
baseadmin: baseadmin:
description: Administer Spigot Base. description: Administer Spigot Base.
@@ -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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Instant;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -36,6 +37,22 @@ final class AdminProgressionServiceTest {
assertFalse(updated.visitorsEnabled()); 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 @Test
void rejectsUnknownLevel() { void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex"); PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -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());
}
}
@@ -24,6 +24,21 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BaseSettingsCommandTest { 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 @Test
void invalidArgumentsShowUsage() { void invalidArgumentsShowUsage() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -58,7 +73,7 @@ final class BaseSettingsCommandTest {
List.of("allowed"), List.of("allowed"),
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"}) command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
); );
for (String setting : List.of("navigation", "flight", "bossbar")) { for (String setting : List.of("navigation", "flight", "border", "bossbar")) {
assertEquals( assertEquals(
List.of("disable"), List.of("disable"),
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"}) command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
@@ -75,7 +90,7 @@ final class BaseSettingsCommandTest {
); );
assertEquals( assertEquals(
List.of("status", "upgrade", "visitors", "navigation", "flight", "bossbar"), List.of("status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar"),
command.onTabComplete(null, null, "basesettings", new String[] {""}) command.onTabComplete(null, null, "basesettings", new String[] {""})
); );
} }
@@ -194,6 +209,7 @@ final class BaseSettingsCommandTest {
message.contains("Settings: visitors=") message.contains("Settings: visitors=")
&& message.contains("navigation=") && message.contains("navigation=")
&& message.contains("flight=") && message.contains("flight=")
&& message.contains("border=")
&& message.contains("bossbar=") && message.contains("bossbar=")
) )
); );
@@ -1,13 +1,31 @@
package games.dmg.spigotbase; 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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class PlayerStateTest { 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 @Test
void rejectsLevelsOutsideKnownRanges() { void rejectsLevelsOutsideKnownRanges() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -28,7 +28,8 @@ final class YamlBaseStateRepositoryTest {
true, true, false, true, true, true, false, true,
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)), Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
Optional.of(Instant.ofEpochMilli(1_750_000_100_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)); PersistentState expected = new PersistentState(Map.of(playerId, player));
YamlBaseStateRepository repository = YamlBaseStateRepository repository =
@@ -39,6 +40,24 @@ final class YamlBaseStateRepositoryTest {
assertEquals(expected, repository.load()); 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 @Test
void invalidRecordsCannotGrantProgression() throws Exception { void invalidRecordsCannotGrantProgression() throws Exception {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();