From 8b3eaad25332b989b555467e4567c447d50645fc Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 24 Aug 2026 13:10:53 -0400 Subject: [PATCH] feat(pocket-base): allow mobs through portals --- README.md | 2 +- design/log.md | 7 + ...s-016-build-and-use-pocket-base-portals.md | 5 + .../dmg/spigotbase/PocketBaseController.java | 78 ++++++++- .../spigotbase/PocketBaseControllerTest.java | 152 ++++++++++++++++++ 5 files changed, 235 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 49f0bf4..fe350f9 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ After 250 Survival-mode block placements anywhere by default, the spawnable over `/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. -Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs `. +Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs `. Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type `. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change. diff --git a/design/log.md b/design/log.md index c7b060d..c53e1ec 100644 --- a/design/log.md +++ b/design/log.md @@ -129,3 +129,10 @@ description: Chronological record of material decisions affecting the Spigot Bas - Compiled and tested against the exact Purpur 26.2 build 2618 API with Java 25, and updated both CI workflows to use Java 25. - Preserved unit-test coverage through server-independent dialog specifications and adapted Bukkit test fixtures to Purpur's registry-aware API behavior. - Verified 128 tests and the Java 25 plugin artifact with `./gradlew clean check jar`. + +## 2026-08-24 — Pocket Base mob portal travel + +- Added five-tick collision scanning that transports non-player living mobs through active Pocket Base entrance and return portals. +- Reused safe player destinations and server-spawn fallback behavior while excluding items, projectiles, vehicles, and other non-living entities. +- Applied the existing two-second portal cooldown to mobs to prevent immediate repeated transport. +- Verified the feature and Java 25 plugin artifact with `./gradlew clean check jar`. diff --git a/design/user-stories/us-016-build-and-use-pocket-base-portals.md b/design/user-stories/us-016-build-and-use-pocket-base-portals.md index 48c541e..382f459 100644 --- a/design/user-stories/us-016-build-and-use-pocket-base-portals.md +++ b/design/user-stories/us-016-build-and-use-pocket-base-portals.md @@ -19,8 +19,13 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w - [x] Activating another valid entrance deactivates the previous entrance without removing its frame blocks. - [x] Breaking any required frame block immediately deactivates the entrance. - [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown. +- [x] Non-player living mobs entering an active entrance portal are teleported into that Pocket Base. +- [x] Portal cooldown handling prevents transported mobs from immediately bouncing back. +- [x] Items, projectiles, vehicles, and other non-living entities are not transported. - [x] Visitors can build, break blocks, open containers, and otherwise interact normally inside the Pocket Base. - [x] The generated return portal sends players safely to the owner's active entrance. +- [x] Non-player living mobs entering the active, intact return portal are teleported safely outside the normal-world entrance. +- [x] Mob return travel falls back safely to the server spawn when the owner has no valid normal-world entrance. - [x] Only the owner can activate a complete portal frame inside their own Pocket Base with flint and steel. - [x] Every required return-frame block must be within the owner's unlocked Pocket Base boundary. - [x] Each Pocket Base has at most one active return portal, and activating another valid frame moves the active return location without removing the old frame. diff --git a/src/main/java/games/dmg/spigotbase/PocketBaseController.java b/src/main/java/games/dmg/spigotbase/PocketBaseController.java index 6949c84..429c7ba 100644 --- a/src/main/java/games/dmg/spigotbase/PocketBaseController.java +++ b/src/main/java/games/dmg/spigotbase/PocketBaseController.java @@ -5,6 +5,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.ChatColor; @@ -15,6 +16,7 @@ import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.entity.Entity; +import org.bukkit.entity.Mob; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -26,6 +28,7 @@ import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.util.BoundingBox; final class PocketBaseController implements Listener, Runnable { private static final int VOID_RETURN_Y = -64; @@ -63,6 +66,7 @@ final class PocketBaseController implements Listener, Runnable { showPortal(pocketBases.returnPortal(state.ownerId())); } } + scanMobPortals(); long now = System.nanoTime(); cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now); } @@ -235,23 +239,61 @@ final class PocketBaseController implements Listener, Runnable { deactivate(ownerId); } - private void leavePocket(Player player, UUID ownerId) { + void scanMobPortals() { + for (PocketBaseState state : pocketBases.knownStates().values()) { + if (state.entrance().isPresent()) { + PocketPortalLocation entrance = state.entrance().orElseThrow(); + if (isIntact(entrance)) { + transportMobs( + entrance, + mob -> teleport(mob, pocketBases.pocketArrival(state.ownerId())) + ); + } + } + if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) { + transportMobs( + pocketBases.returnPortal(state.ownerId()), + mob -> leavePocket(mob, state.ownerId()) + ); + } + } + } + + private void transportMobs( + PocketPortalLocation portal, + Consumer transport + ) { + World world = server.getWorld(portal.worldId()); + if (world == null) { + return; + } + for (Entity entity : world.getNearbyEntities( + portalBounds(portal), + candidate -> candidate instanceof Mob + )) { + if (entity instanceof Mob mob && !onCooldown(mob)) { + transport.accept(mob); + } + } + } + + private void leavePocket(Entity entity, UUID ownerId) { PocketBaseState state = pocketBases.state(ownerId); if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) { - teleport(player, outsidePortal(state.entrance().orElseThrow())); + teleport(entity, outsidePortal(state.entrance().orElseThrow())); return; } World fallbackWorld = server.getWorlds().get(0); - teleport(player, fallbackWorld.getSpawnLocation()); + teleport(entity, fallbackWorld.getSpawnLocation()); } - private void teleport(Player player, Location destination) { - cooldownUntil.put(player.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS); - player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); + private void teleport(Entity entity, Location destination) { + cooldownUntil.put(entity.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS); + entity.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); } - private boolean onCooldown(Player player) { - return cooldownUntil.getOrDefault(player.getUniqueId(), 0L) > System.nanoTime(); + private boolean onCooldown(Entity entity) { + return cooldownUntil.getOrDefault(entity.getUniqueId(), 0L) > System.nanoTime(); } private void validateEntrances() { @@ -320,6 +362,26 @@ final class PocketBaseController implements Listener, Runnable { : new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0); } + private static BoundingBox portalBounds(PocketPortalLocation portal) { + return portal.axis() == PocketPortalAxis.X + ? new BoundingBox( + portal.x() + 1.0, + portal.y() + 1.0, + portal.z(), + portal.x() + 3.0, + portal.y() + 4.0, + portal.z() + 1.0 + ) + : new BoundingBox( + portal.x(), + portal.y() + 1.0, + portal.z() + 1.0, + portal.x() + 1.0, + portal.y() + 4.0, + portal.z() + 3.0 + ); + } + private static boolean isAir(Material material) { return material == Material.AIR || material == Material.CAVE_AIR || material == Material.VOID_AIR; diff --git a/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java b/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java index 9e04561..eafa49c 100644 --- a/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java +++ b/src/test/java/games/dmg/spigotbase/PocketBaseControllerTest.java @@ -1,5 +1,6 @@ package games.dmg.spigotbase; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -8,20 +9,26 @@ import static org.mockito.Mockito.when; import java.time.Instant; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.logging.Logger; +import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Mob; import org.bukkit.entity.Player; import org.bukkit.event.block.Action; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.util.BoundingBox; import org.junit.jupiter.api.Test; final class PocketBaseControllerTest { @@ -230,6 +237,151 @@ final class PocketBaseControllerTest { verify(event).setCancelled(true); } + @Test + void activeEntranceTransportsMobsOnceButNotOtherEntities() { + UUID ownerId = UUID.randomUUID(); + UUID worldId = UUID.randomUUID(); + PocketPortalLocation portal = new PocketPortalLocation( + worldId, "world", -2, 64, 0, PocketPortalAxis.X + ); + PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(portal)); + Server server = mock(Server.class); + World world = intactPortalWorld(worldId, portal); + Mob mob = mock(Mob.class); + Entity item = mock(Entity.class); + Location arrival = new Location(mock(World.class), 0.5, 65.0, 5.5); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + + when(mob.getUniqueId()).thenReturn(UUID.randomUUID()); + when(item.getUniqueId()).thenReturn(UUID.randomUUID()); + when(world.getNearbyEntities(any(BoundingBox.class), any())) + .thenReturn(List.of(mob, item)); + when(server.getWorld(worldId)).thenReturn(world); + when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state)); + when(pocketBases.pocketArrival(ownerId)).thenReturn(arrival); + PocketBaseController controller = controller(server, pocketBases); + + controller.scanMobPortals(); + controller.scanMobPortals(); + + verify(mob).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN); + verify(item, never()).teleport( + any(Location.class), + any(PlayerTeleportEvent.TeleportCause.class) + ); + } + + @Test + void activeReturnPortalTransportsMobOutsideIntactEntrance() { + UUID ownerId = UUID.randomUUID(); + UUID normalWorldId = UUID.randomUUID(); + UUID pocketWorldId = UUID.randomUUID(); + PocketPortalLocation entrance = new PocketPortalLocation( + normalWorldId, "world", -2, 64, 0, PocketPortalAxis.X + ); + PocketPortalLocation returnPortal = new PocketPortalLocation( + pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z + ); + PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(entrance)); + Server server = mock(Server.class); + World normalWorld = intactPortalWorld(normalWorldId, entrance); + World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal); + Mob mob = mock(Mob.class); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + + when(mob.getUniqueId()).thenReturn(UUID.randomUUID()); + when(normalWorld.getNearbyEntities(any(BoundingBox.class), any())) + .thenReturn(List.of()); + when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any())) + .thenReturn(List.of(mob)); + when(server.getWorld(normalWorldId)).thenReturn(normalWorld); + when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld); + when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state)); + when(pocketBases.state(ownerId)).thenReturn(state); + when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true); + when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal); + PocketBaseController controller = controller(server, pocketBases); + + controller.scanMobPortals(); + + verify(mob).teleport( + new Location(normalWorld, 0.0, 65.0, 1.5), + PlayerTeleportEvent.TeleportCause.PLUGIN + ); + } + + @Test + void mobReturnFallsBackToServerSpawnWithoutValidEntrance() { + UUID ownerId = UUID.randomUUID(); + UUID pocketWorldId = UUID.randomUUID(); + PocketPortalLocation returnPortal = new PocketPortalLocation( + pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z + ); + PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty()); + Server server = mock(Server.class); + World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal); + World fallbackWorld = mock(World.class); + Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5); + Mob mob = mock(Mob.class); + PocketBaseManager pocketBases = mock(PocketBaseManager.class); + + when(mob.getUniqueId()).thenReturn(UUID.randomUUID()); + when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any())) + .thenReturn(List.of(mob)); + when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld); + when(server.getWorlds()).thenReturn(List.of(fallbackWorld)); + when(fallbackWorld.getSpawnLocation()).thenReturn(spawn); + when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state)); + when(pocketBases.state(ownerId)).thenReturn(state); + when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true); + when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal); + PocketBaseController controller = controller(server, pocketBases); + + controller.scanMobPortals(); + + verify(mob).teleport( + spawn, + PlayerTeleportEvent.TeleportCause.PLUGIN + ); + } + + private static PocketBaseController controller( + Server server, + PocketBaseManager pocketBases + ) { + PluginSettings settings = PluginSettings.from(Map.of()); + return new PocketBaseController( + server, + mock(BaseStateManager.class), + new BaseBoundsService(settings), + pocketBases, + new PluginSettingsProvider(settings), + Logger.getAnonymousLogger() + ); + } + + private static World intactPortalWorld( + UUID worldId, + PocketPortalLocation portal + ) { + World world = mock(World.class); + Set frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal)); + when(world.getUID()).thenReturn(worldId); + when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { + BlockPosition position = new BlockPosition( + invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2) + ); + return block( + world, + position.x(), + position.y(), + position.z(), + frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR + ); + }); + return world; + } + private static ItemStack item(Material material) { ItemStack item = mock(ItemStack.class); when(item.getType()).thenReturn(material);