feat(border): add base boundary particles
This commit is contained in:
@@ -31,6 +31,7 @@ The plugin JAR is written to `build/libs/`.
|
||||
/basesettings visitors <allowed|blocked>
|
||||
/basesettings navigation <enable|disable>
|
||||
/basesettings flight <enable|disable>
|
||||
/basesettings border <enable|disable>
|
||||
/basesettings bossbar <enable|disable>
|
||||
```
|
||||
|
||||
|
||||
@@ -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`.
|
||||
- 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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
+ " 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"));
|
||||
|
||||
@@ -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 {
|
||||
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> ENABLE_MODES = List.of("enable", "disable");
|
||||
@@ -59,6 +59,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
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]);
|
||||
}
|
||||
@@ -189,6 +192,29 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
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) {
|
||||
@@ -218,6 +244,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
+ (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() + " "
|
||||
@@ -306,7 +333,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
|
||||
? VISITOR_MODES
|
||||
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||
case "navigation", "flight", "bossbar" -> ENABLE_MODES;
|
||||
case "navigation", "flight", "border", "bossbar" -> ENABLE_MODES;
|
||||
default -> List.of();
|
||||
};
|
||||
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||
@@ -372,6 +399,6 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
private static void sendUsage(Player player) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
|
||||
+ "[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,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,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)
|
||||
|
||||
@@ -19,7 +19,7 @@ commands:
|
||||
aliases: [visit]
|
||||
basesettings:
|
||||
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]
|
||||
baseadmin:
|
||||
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.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,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;
|
||||
|
||||
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();
|
||||
@@ -58,7 +73,7 @@ final class BaseSettingsCommandTest {
|
||||
List.of("allowed"),
|
||||
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(
|
||||
List.of("disable"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
|
||||
@@ -75,7 +90,7 @@ final class BaseSettingsCommandTest {
|
||||
);
|
||||
|
||||
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[] {""})
|
||||
);
|
||||
}
|
||||
@@ -194,6 +209,7 @@ final class BaseSettingsCommandTest {
|
||||
message.contains("Settings: visitors=")
|
||||
&& message.contains("navigation=")
|
||||
&& message.contains("flight=")
|
||||
&& message.contains("border=")
|
||||
&& message.contains("bossbar=")
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user