From f36c40d5aa344b435c66ec39ae2c274aa544e84a Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 22:51:19 -0400 Subject: [PATCH] fix(navigation): hide guidance near base --- README.md | 2 + design/log.md | 7 +++ .../us-002-unlock-base-navigation.md | 4 +- .../spigotbase/BaseNavigationController.java | 13 +++- .../spigotbase/BaseNavigationVisibility.java | 26 ++++++++ .../BaseNavigationControllerTest.java | 60 +++++++++++++++++++ .../BaseNavigationVisibilityTest.java | 26 ++++++++ 7 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 src/main/java/games/dmg/spigotbase/BaseNavigationVisibility.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseNavigationControllerTest.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseNavigationVisibilityTest.java diff --git a/README.md b/README.md index e5bd742..dc85597 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ The plugin JAR is written to `build/libs/`. /basesettings bossbar ``` +Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border. + After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable. `/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. diff --git a/design/log.md b/design/log.md index 54563d1..af96eca 100644 --- a/design/log.md +++ b/design/log.md @@ -84,3 +84,10 @@ description: Chronological record of material decisions affecting the Spigot Bas - Added admin controls for total placement progress, the unlock threshold, and each unlocked player's overlay preference. - Added owner-only red particles for nearby dark spawning surfaces inside base bounds, using bounded incremental scanning and rendering budgets. - Verified the feature with `./gradlew clean check jar`. + +## 2026-08-10 — Navigation visibility buffer + +- Limited enabled base navigation particles to the base's world and positions more than 25 horizontal blocks beyond the current base border. +- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold. +- Added exact-boundary and controller integration coverage. +- Verified the change with `./gradlew clean check jar`. diff --git a/design/user-stories/us-002-unlock-base-navigation.md b/design/user-stories/us-002-unlock-base-navigation.md index ac3e325..99cb1fe 100644 --- a/design/user-stories/us-002-unlock-base-navigation.md +++ b/design/user-stories/us-002-unlock-base-navigation.md @@ -14,7 +14,9 @@ As a **player with Base I**, I want visual guidance toward my base so that I can - [x] Base II requires Base I and an established base. - [x] Base II unlocks at a configurable cumulative grass-or-dirt threshold that defaults to 500 blocks, 250 more than Base I. - [x] Base II provides particle-based navigation and does not grant or require a physical compass item. -- [x] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base. +- [x] While enabled and in the base's world, particles are drawn along the ground only when the player is horizontally more than 25 blocks beyond the current base border. +- [x] No navigation particles appear inside the base, exactly 25 blocks beyond its border, or anywhere within that buffer. +- [x] Expansion and relocation update the navigation visibility threshold immediately. - [x] Particle generation is bounded to avoid excessive server or client load. - [x] A player in another world receives a clear message instead of a misleading particle direction. - [x] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently. diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationController.java b/src/main/java/games/dmg/spigotbase/BaseNavigationController.java index 11488c2..450d216 100644 --- a/src/main/java/games/dmg/spigotbase/BaseNavigationController.java +++ b/src/main/java/games/dmg/spigotbase/BaseNavigationController.java @@ -10,6 +10,7 @@ final class BaseNavigationController implements Runnable { private final Server server; private final BaseStateManager stateManager; private final PluginSettingsProvider settings; + private final BaseBoundsService boundsService; BaseNavigationController( Server server, @@ -19,6 +20,7 @@ final class BaseNavigationController implements Runnable { this.server = server; this.stateManager = stateManager; this.settings = settings; + this.boundsService = new BaseBoundsService(settings); } @Override @@ -32,7 +34,16 @@ final class BaseNavigationController implements Runnable { if (!player.getWorld().getUID().equals(base.worldId())) { continue; } - Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0); + Location playerLocation = player.getLocation(); + if (!BaseNavigationVisibility.isBeyondBorderBuffer( + base, + boundsService.radius(state), + playerLocation.getX(), + playerLocation.getZ() + )) { + continue; + } + Location origin = playerLocation.clone().add(0.0, 0.15, 0.0); Vector direction = new Vector( base.x() + 0.5 - origin.getX(), 0.0, diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationVisibility.java b/src/main/java/games/dmg/spigotbase/BaseNavigationVisibility.java new file mode 100644 index 0000000..b089118 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseNavigationVisibility.java @@ -0,0 +1,26 @@ +package games.dmg.spigotbase; + +final class BaseNavigationVisibility { + static final int BORDER_BUFFER = 25; + + private BaseNavigationVisibility() { + } + + static boolean isBeyondBorderBuffer( + BaseLocation base, + int radius, + double playerX, + double playerZ + ) { + if (base == null) { + throw new IllegalArgumentException("base is required"); + } + if (radius <= 0) { + throw new IllegalArgumentException("base radius must be positive"); + } + double deltaX = playerX - (base.x() + 0.5); + double deltaZ = playerZ - (base.z() + 0.5); + double minimumDistance = radius + BORDER_BUFFER; + return deltaX * deltaX + deltaZ * deltaZ > minimumDistance * minimumDistance; + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseNavigationControllerTest.java b/src/test/java/games/dmg/spigotbase/BaseNavigationControllerTest.java new file mode 100644 index 0000000..2c0ca3f --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseNavigationControllerTest.java @@ -0,0 +1,60 @@ +package games.dmg.spigotbase; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.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 BaseNavigationControllerTest { + @Test + void showsParticlesOnlyMoreThanTwentyFiveBlocksBeyondBorder() { + UUID playerId = UUID.randomUUID(); + UUID worldId = UUID.randomUUID(); + PlayerState state = PlayerState.newPlayer(playerId, "Builder") + .withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false) + .withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH); + 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, 35.5, 64.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); + PluginSettingsProvider settings = new PluginSettingsProvider( + PluginSettings.from(Map.of()) + ); + + new BaseNavigationController(server, stateManager, settings).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) + ); + + when(player.getLocation()).thenReturn(new Location(world, 35.51, 64.0, 0.5)); + new BaseNavigationController(server, stateManager, settings).run(); + + verify(player, times(settings.current().navigationParticleCount())).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/BaseNavigationVisibilityTest.java b/src/test/java/games/dmg/spigotbase/BaseNavigationVisibilityTest.java new file mode 100644 index 0000000..2ff9e15 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseNavigationVisibilityTest.java @@ -0,0 +1,26 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class BaseNavigationVisibilityTest { + private static final BaseLocation BASE = new BaseLocation( + UUID.randomUUID(), "world", 0, 64, 0, 0, 0 + ); + + @Test + void hidesNavigationAtAndWithinTwentyFiveBlocksBeyondBorder() { + assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.5, 0.5)); + assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 20.5, 0.5)); + } + + @Test + void showsNavigationMoreThanTwentyFiveBlocksBeyondCurrentBorder() { + assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.51, 0.5)); + assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.5, 0.5)); + assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.51, 0.5)); + } +}