From 26aeb8c41638d0cf02ed6e72b153ea2efe1a86b6 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 22:12:16 -0400 Subject: [PATCH] feat(border): add base boundary particles --- README.md | 1 + design/log.md | 7 ++ design/user-stories/index.md | 1 + .../us-013-visualize-base-border.md | 31 +++++++ .../dmg/spigotbase/BaseAdminCommand.java | 4 +- .../dmg/spigotbase/BaseBorderController.java | 62 +++++++++++++ .../dmg/spigotbase/BaseBorderGeometry.java | 58 ++++++++++++ .../dmg/spigotbase/BaseSettingsCommand.java | 33 ++++++- .../games/dmg/spigotbase/PlayerState.java | 81 ++++++++++++---- .../dmg/spigotbase/SpigotBasePlugin.java | 6 ++ .../spigotbase/YamlBaseStateRepository.java | 4 +- src/main/resources/plugin.yml | 2 +- .../AdminProgressionServiceTest.java | 17 ++++ .../spigotbase/BaseBorderControllerTest.java | 92 +++++++++++++++++++ .../spigotbase/BaseBorderGeometryTest.java | 27 ++++++ .../spigotbase/BaseSettingsCommandTest.java | 20 +++- .../games/dmg/spigotbase/PlayerStateTest.java | 18 ++++ .../YamlBaseStateRepositoryTest.java | 21 ++++- 18 files changed, 458 insertions(+), 27 deletions(-) create mode 100644 design/user-stories/us-013-visualize-base-border.md create mode 100644 src/main/java/games/dmg/spigotbase/BaseBorderController.java create mode 100644 src/main/java/games/dmg/spigotbase/BaseBorderGeometry.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseBorderControllerTest.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseBorderGeometryTest.java diff --git a/README.md b/README.md index 05255e4..25875b7 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ The plugin JAR is written to `build/libs/`. /basesettings visitors /basesettings navigation /basesettings flight +/basesettings border /basesettings bossbar ``` diff --git a/design/log.md b/design/log.md index 91a40ba..9993268 100644 --- a/design/log.md +++ b/design/log.md @@ -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 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`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 779a099..cb1a3d6 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -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) diff --git a/design/user-stories/us-013-visualize-base-border.md b/design/user-stories/us-013-visualize-base-border.md new file mode 100644 index 0000000..91fbb63 --- /dev/null +++ b/design/user-stories/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) diff --git a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java index e1d7920..452c6e9 100644 --- a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java @@ -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")); diff --git a/src/main/java/games/dmg/spigotbase/BaseBorderController.java b/src/main/java/games/dmg/spigotbase/BaseBorderController.java new file mode 100644 index 0000000..e60b785 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseBorderController.java @@ -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 + ); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseBorderGeometry.java b/src/main/java/games/dmg/spigotbase/BaseBorderGeometry.java new file mode 100644 index 0000000..96e851a --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseBorderGeometry.java @@ -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 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 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) { + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java index 183098a..7ad56f1 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -15,7 +15,7 @@ import org.bukkit.inventory.PlayerInventory; final class BaseSettingsCommand implements CommandExecutor, TabCompleter { private static final List SETTINGS = List.of( - "status", "upgrade", "visitors", "navigation", "flight", "bossbar" + "status", "upgrade", "visitors", "navigation", "flight", "border", "bossbar" ); private static final List VISITOR_MODES = List.of("allowed", "blocked"); private static final List 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 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 |navigation " - + "|flight |bossbar ]"); + + "|flight |border |bossbar ]"); } } diff --git a/src/main/java/games/dmg/spigotbase/PlayerState.java b/src/main/java/games/dmg/spigotbase/PlayerState.java index 5caca5b..2e1daa6 100644 --- a/src/main/java/games/dmg/spigotbase/PlayerState.java +++ b/src/main/java/games/dmg/spigotbase/PlayerState.java @@ -26,7 +26,8 @@ public record PlayerState( boolean visitorsEnabled, Optional lastBaseSet, Optional lastBaseTeleport, - Map visitorCooldownUntil + Map 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 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 lastBaseSet, + Optional lastBaseTeleport, + Map 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 ); } diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index 1356ab5..bf85e98 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -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."); diff --git a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java index 8f8bc5c..7e0688a 100644 --- a/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java +++ b/src/main/java/games/dmg/spigotbase/YamlBaseStateRepository.java @@ -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) diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5419ce1..895896c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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. diff --git a/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java b/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java index c2188c9..ed9f22c 100644 --- a/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java +++ b/src/test/java/games/dmg/spigotbase/AdminProgressionServiceTest.java @@ -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"); diff --git a/src/test/java/games/dmg/spigotbase/BaseBorderControllerTest.java b/src/test/java/games/dmg/spigotbase/BaseBorderControllerTest.java new file mode 100644 index 0000000..d564d5e --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseBorderControllerTest.java @@ -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) + ); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseBorderGeometryTest.java b/src/test/java/games/dmg/spigotbase/BaseBorderGeometryTest.java new file mode 100644 index 0000000..9cfe209 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseBorderGeometryTest.java @@ -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 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()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java index be5a59b..3a5540c 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -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=") ) ); diff --git a/src/test/java/games/dmg/spigotbase/PlayerStateTest.java b/src/test/java/games/dmg/spigotbase/PlayerStateTest.java index 7486f01..489e7c3 100644 --- a/src/test/java/games/dmg/spigotbase/PlayerStateTest.java +++ b/src/test/java/games/dmg/spigotbase/PlayerStateTest.java @@ -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(); diff --git a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java index f51da15..50b020e 100644 --- a/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java +++ b/src/test/java/games/dmg/spigotbase/YamlBaseStateRepositoryTest.java @@ -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();