2 Commits
Author SHA1 Message Date
dmg 4518478ea1 feat(pocket-base): allow return portal relocation
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m13s
2026-08-24 10:06:25 -04:00
dmg 23e8fcec6e feat(pocket-base): add mob spawning toggle
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m28s
2026-08-24 09:57:48 -04:00
16 changed files with 658 additions and 29 deletions
+2 -1
View File
@@ -29,6 +29,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings status
/basesettings upgrade
/basesettings pocket upgrade
/basesettings pocket mobs <enable|disable>
/basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable>
/basesettings flight <enable|disable>
@@ -43,7 +44,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.
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 and can be controlled by the owner with `/basesettings pocket mobs <enable|disable>`.
## Administration
+14
View File
@@ -91,3 +91,17 @@ description: Chronological record of material decisions affecting the Spigot Bas
- 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`.
## 2026-08-24 — Pocket Base mob-spawning control
- Disabled natural hostile and passive mob spawning by default in new and existing Pocket Bases without a saved preference.
- Added the persisted owner setting `/basesettings pocket mobs enable|disable`, contextual autocomplete, and status reporting.
- Reapplied saved spawn flags when Pocket Base worlds load and restored the prior world setting when persistence fails.
- Verified the feature with `./gradlew clean check jar`.
## 2026-08-24 — Relocatable Pocket Base return portals
- Allowed Pocket Base owners to activate a complete return portal frame with flint and steel anywhere inside their unlocked pocket boundary.
- Persisted one active return portal location while retaining the generated portal as the default for existing Pocket Bases.
- Made only the selected intact frame functional without removing old frame blocks, preserving the void-return safety fallback.
- Verified the feature with `./gradlew clean check jar`.
+1
View File
@@ -23,3 +23,4 @@ description: Catalog of user stories for the Spigot Base plugin.
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
16. [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
@@ -21,7 +21,12 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown.
- [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] Return travel falls back to the server spawn when the owner has no valid active 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.
- [x] Relocated return portals persist through restarts, while existing Pocket Bases use the generated portal as their default.
- [x] Breaking the active return frame disables return travel through it.
- [x] Return travel falls back to the server spawn when the owner has no valid normal-world entrance.
- [x] Falling below Y -64 performs the same safe return without void damage.
- [x] Players who disconnect inside a Pocket Base remain there when they reconnect.
- [x] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents.
@@ -0,0 +1,28 @@
---
type: User Story
title: "US-018: Control Pocket Base mob spawning"
description: Let Pocket Base owners control natural mob spawning in their private world, with spawning disabled by default.
status: done
---
# US-018: Control Pocket Base mob spawning
As a **Pocket Base owner**, I want to enable or disable natural mob spawning in my Pocket Base so that I can choose whether the private world supports normal mob activity.
## Acceptance criteria
- [x] Natural mob spawning is disabled by default in every new Pocket Base and in existing Pocket Bases that have no saved preference.
- [x] A Pocket Base owner can use `/basesettings pocket mobs enable|disable` to control natural mob spawning in their own Pocket Base.
- [x] The setting controls both hostile and passive natural spawning only in the owner's Pocket Base.
- [x] The command is unavailable until Pocket Base I is unlocked.
- [x] The command and its modes are offered through contextual autocomplete.
- [x] `/basesettings status` displays the current Pocket Base mob-spawning setting.
- [x] The preference persists across restarts and is reapplied when the Pocket Base world loads.
- [x] Disabling natural spawning does not remove existing mobs or prevent explicitly spawned or summoned mobs.
- [x] Persistence or world-application failures produce a clear failure message without reporting a successful change.
## Related
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
@@ -66,6 +66,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
&& arguments[1].equalsIgnoreCase("upgrade")) {
return purchasePocketUpgrade(player, state);
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]);
}
@@ -176,6 +180,37 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true;
}
private boolean updatePocketMobSpawning(Player player, String mode) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (current.level() < 1) {
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
final PocketBaseState updated;
try {
updated = pocketBases.setMobSpawning(player.getUniqueId(), enabled);
} catch (IOException | RuntimeException exception) {
player.sendMessage(ChatColor.RED
+ "The Pocket Base mob-spawning setting could not be changed.");
return true;
}
player.sendMessage(ChatColor.YELLOW + "Pocket Base mob spawning is now "
+ (updated.mobSpawningEnabled()
? ChatColor.GREEN + "enabled"
: ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateVisitors(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
@@ -408,14 +443,17 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
}
private void showPocketPath(Player player, PlayerState owner) {
int level = pocketBases == null ? 0 : pocketBases.state(owner.playerId()).level();
int price = level == 0
PocketBaseState pocket = pocketBases == null
? PocketBaseState.locked(owner.playerId())
: pocketBases.state(owner.playerId());
int price = pocket.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
+ "Pocket Base " + level + ": " + ChatColor.GRAY + price + " "
+ "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
+ " → /basesettings pocket upgrade");
+ " → /basesettings pocket upgrade; mob spawning="
+ (pocket.mobSpawningEnabled() ? "enabled" : "disabled"));
}
private void showCooldownPath(Player player, PlayerState state) {
@@ -447,13 +485,18 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "pocket" -> List.of("upgrade");
case "pocket" -> List.of("upgrade", "mobs");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) {
String prefix = arguments[2].toLowerCase(Locale.ROOT);
return ENABLE_MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
return List.of();
}
@@ -513,8 +556,8 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|pocket upgrade|visitors <allowed|blocked>"
+ "|navigation <enable|disable>"
+ "[status|upgrade|pocket upgrade|pocket mobs <enable|disable>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>"
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
}
@@ -79,10 +79,6 @@ final class PocketBaseController implements Listener, Runnable {
if (pocket.level() < 1) {
return;
}
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return;
}
Block clicked = event.getClickedBlock();
World world = clicked.getWorld();
Material frameMaterial = Material.valueOf(
@@ -98,6 +94,37 @@ final class PocketBaseController implements Listener, Runnable {
if (portal.isEmpty()) {
return;
}
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) {
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
return;
}
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
.anyMatch(candidate -> !pocketBases.policy().contains(
pocket.level(), candidate.x(), candidate.z()
))) {
player.sendMessage(ChatColor.RED
+ "The complete return portal must be inside your Pocket Base boundary.");
event.setCancelled(true);
return;
}
try {
pocketBases.activateReturnPortal(player.getUniqueId(), portal.orElseThrow());
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception);
player.sendMessage(ChatColor.RED
+ "The Pocket Base return portal could not be activated.");
event.setCancelled(true);
return;
}
event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base return portal activated.");
return;
}
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return;
}
BaseArea area = baseBounds.area(owner);
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
@@ -60,6 +60,38 @@ final class PocketBaseManager {
}
}
PocketBaseState activateReturnPortal(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
return states.updateAndSave(ownerId, state -> state.withReturnPortal(portal));
}
PocketBaseState setMobSpawning(UUID ownerId, boolean enabled) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
worlds.setMobSpawning(ownerId, enabled);
try {
return states.updateAndSave(
ownerId,
state -> state.withMobSpawningEnabled(enabled)
);
} catch (IOException | RuntimeException exception) {
try {
worlds.setMobSpawning(ownerId, current.mobSpawningEnabled());
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
throw exception;
}
}
Optional<UUID> ownerForPocketWorld(UUID worldId) {
return worlds.ownerForWorld(worldId);
}
@@ -69,11 +101,13 @@ final class PocketBaseManager {
}
PocketPortalLocation returnPortal(UUID ownerId) {
return worlds.returnPortal(ownerId);
return states.state(ownerId).returnPortal().orElseGet(
() -> worlds.returnPortal(ownerId)
);
}
boolean returnPortalIsIntact(UUID ownerId) {
return worlds.returnPortalIsIntact(ownerId);
return worlds.returnPortalIsIntact(ownerId, returnPortal(ownerId));
}
World pocketWorld(UUID ownerId) {
@@ -6,7 +6,9 @@ import java.util.UUID;
public record PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean mobSpawningEnabled
) {
public PocketBaseState {
if (ownerId == null) {
@@ -16,24 +18,60 @@ public record PocketBaseState(
throw new IllegalArgumentException("Pocket Base level must not be negative");
}
entrance = entrance == null ? Optional.empty() : entrance;
if (level == 0 && entrance.isPresent()) {
throw new IllegalArgumentException("a locked Pocket Base cannot have an entrance");
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
) {
this(ownerId, level, entrance, Optional.empty(), false);
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
boolean mobSpawningEnabled
) {
this(ownerId, level, entrance, Optional.empty(), mobSpawningEnabled);
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(ownerId, 0, Optional.empty());
return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false
);
}
public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(ownerId, newLevel, entrance);
return new PocketBaseState(
ownerId, newLevel, entrance, returnPortal, mobSpawningEnabled
);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(ownerId, level, Optional.of(portal));
return new PocketBaseState(
ownerId, level, Optional.of(portal), returnPortal, mobSpawningEnabled
);
}
public PocketBaseState withoutEntrance() {
return new PocketBaseState(ownerId, level, Optional.empty());
return new PocketBaseState(
ownerId, level, Optional.empty(), returnPortal, mobSpawningEnabled
);
}
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, entrance, Optional.of(portal), mobSpawningEnabled
);
}
public PocketBaseState withMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(ownerId, level, entrance, returnPortal, enabled);
}
}
@@ -27,7 +27,7 @@ final class PocketBaseWorldService {
void loadExisting(Map<UUID, PocketBaseState> states) {
for (PocketBaseState state : states.values()) {
if (state.level() > 0) {
ensureWorld(state.ownerId());
setMobSpawning(state.ownerId(), state.mobSpawningEnabled());
}
}
}
@@ -66,6 +66,10 @@ final class PocketBaseWorldService {
return ensureWorld(ownerId);
}
void setMobSpawning(UUID ownerId, boolean enabled) {
ensureWorld(ownerId).setSpawnFlags(enabled, enabled);
}
Location arrival(UUID ownerId) {
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
}
@@ -77,9 +81,11 @@ final class PocketBaseWorldService {
);
}
boolean returnPortalIsIntact(UUID ownerId) {
PocketPortalLocation portal = returnPortal(ownerId);
boolean returnPortalIsIntact(UUID ownerId, PocketPortalLocation portal) {
World world = ensureWorld(ownerId);
if (!portal.worldId().equals(world.getUID())) {
return false;
}
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> world.getBlockAt(
@@ -90,7 +96,8 @@ final class PocketBaseWorldService {
private World ensureWorld(UUID ownerId) {
String name = worldName(ownerId);
World world = server.getWorld(name);
if (world == null) {
boolean created = world == null;
if (created) {
WorldCreator creator = new WorldCreator(name)
.environment(World.Environment.NORMAL)
.generator(new VoidPocketChunkGenerator())
@@ -100,6 +107,9 @@ final class PocketBaseWorldService {
if (world == null) {
throw new IllegalStateException("Could not create Pocket Base world " + name);
}
if (created) {
world.setSpawnFlags(false, false);
}
ownersByWorld.put(world.getUID(), ownerId);
return world;
}
@@ -42,7 +42,9 @@ public final class YamlPocketBaseRepository {
PocketBaseState state = new PocketBaseState(
ownerId,
level,
loadPortal(yaml, path + ".entrance")
loadPortal(yaml, path + ".entrance"),
loadPortal(yaml, path + ".return-portal"),
yaml.getBoolean(path + ".mob-spawning-enabled", false)
);
states.put(ownerId, state);
} catch (IllegalArgumentException ignored) {
@@ -61,7 +63,11 @@ public final class YamlPocketBaseRepository {
for (PocketBaseState state : states.values()) {
String path = "owners." + state.ownerId();
yaml.set(path + ".level", state.level());
yaml.set(path + ".mob-spawning-enabled", state.mobSpawningEnabled());
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
state.returnPortal().ifPresent(portal ->
savePortal(yaml, path + ".return-portal", portal)
);
}
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
try {
@@ -94,7 +100,7 @@ public final class YamlPocketBaseRepository {
if (worldId == null || worldName == null || axis == null
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|| !yaml.isInt(path + ".z")) {
throw new IllegalArgumentException("invalid Pocket Base entrance");
throw new IllegalArgumentException("invalid Pocket Base portal");
}
return Optional.of(new PocketPortalLocation(
UUID.fromString(worldId),
@@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
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;
@@ -95,6 +96,16 @@ final class BaseSettingsCommandTest {
List.of("upgrade"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
);
assertEquals(
List.of("mobs"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
);
assertEquals(
List.of("enable"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "mobs", "e"}
)
);
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals(
List.of("disable"),
@@ -198,6 +209,101 @@ final class BaseSettingsCommandTest {
verify(inventory).setStorageContents(any(ItemStack[].class));
}
@Test
void pocketOwnerCanEnableNaturalMobSpawning() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
);
when(pocketBases.setMobSpawning(playerId, true)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), true)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
);
verify(pocketBases).setMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("mob spawning") && message.contains("enabled")
));
}
@Test
void failedPocketMobSpawningChangeReportsOnlyFailure() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
);
when(pocketBases.setMobSpawning(playerId, true))
.thenThrow(new IOException("save failed"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("could not be changed")
));
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("is now")
));
}
@Test
void lockedPlayerCannotEnablePocketMobSpawning() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
);
verify(pocketBases, never()).setMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base I") && message.contains("locked")
));
}
@Test
void failedPocketUpgradeRestoresPayment() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -236,6 +342,34 @@ final class BaseSettingsCommandTest {
assertEquals(64, restored[0].getAmount());
}
@Test
void statusDisplaysPocketMobSpawningPreference() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base 1")
&& message.contains("mob spawning=disabled")
));
}
@Test
void bossbarEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
@@ -2,6 +2,7 @@ package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -85,6 +86,114 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true);
}
@Test
void ownerCanActivateCompleteReturnFrameInsideOwnPocketBase() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
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
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onActivate(event);
verify(pocketBases).activateReturnPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 30, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
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
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onActivate(event);
verify(pocketBases, never()).activateReturnPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
UUID ownerId = UUID.randomUUID();
@@ -0,0 +1,108 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.UnaryOperator;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
final class PocketBaseManagerTest {
@Test
void relocatedReturnPortalOverridesGeneratedDefault() {
UUID ownerId = UUID.randomUUID();
PocketPortalLocation relocated = new PocketPortalLocation(
UUID.randomUUID(), "pocket", 20, 65, 12, PocketPortalAxis.Z
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.of(relocated), false
));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(relocated, manager.returnPortal(ownerId));
}
@Test
void existingStateWithoutRelocationUsesGeneratedReturnPortal() {
UUID ownerId = UUID.randomUUID();
PocketPortalLocation generated = new PocketPortalLocation(
UUID.randomUUID(), "pocket", -2, 65, 0, PocketPortalAxis.X
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(worlds.returnPortal(ownerId)).thenReturn(generated);
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(generated, manager.returnPortal(ownerId));
}
@Test
void persistsAndAppliesMobSpawningPreference() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
manager.setMobSpawning(ownerId, true);
InOrder order = inOrder(worlds, states);
order.verify(worlds).setMobSpawning(ownerId, true);
order.verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void restoresWorldSettingWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertThrows(IOException.class, () -> manager.setMobSpawning(ownerId, true));
verify(worlds).setMobSpawning(ownerId, true);
verify(worlds).setMobSpawning(ownerId, false);
}
}
@@ -1,6 +1,8 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -9,6 +11,7 @@ import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.Server;
@@ -41,9 +44,57 @@ final class PocketBaseWorldServiceTest {
verify(blocks.get(new BlockPosition(-2, 65, 0)))
.setType(Material.DIAMOND_BLOCK, false);
verify(world).setSpawnLocation(0, 65, 5);
verify(world).setSpawnFlags(false, false);
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow());
}
@Test
void checksRelocatedReturnPortalFrameAtItsSavedLocation() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
Map<BlockPosition, Block> blocks = blocks(world);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
);
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
when(world.getBlockAt(position.x(), position.y(), position.z()).getType())
.thenReturn(Material.DIAMOND_BLOCK);
}
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertTrue(service.returnPortalIsIntact(ownerId, portal));
when(world.getBlockAt(20, 65, 12).getType()).thenReturn(Material.AIR);
assertFalse(service.returnPortalIsIntact(ownerId, portal));
}
@Test
void reappliesSavedMobSpawningPreferenceWhenWorldLoads() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
service.loadExisting(Map.of(
ownerId,
new PocketBaseState(ownerId, 1, Optional.empty(), true)
));
verify(world).setSpawnFlags(true, true);
}
@Test
void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
UUID ownerId = UUID.randomUUID();
@@ -1,7 +1,10 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
@@ -22,7 +25,11 @@ final class YamlPocketBaseRepositoryTest {
7,
Optional.of(new PocketPortalLocation(
worldId, "world", 10, 65, -4, PocketPortalAxis.X
))
)),
Optional.of(new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
)),
true
);
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml")
@@ -32,4 +39,17 @@ final class YamlPocketBaseRepositoryTest {
assertEquals(expected, repository.load().get(ownerId));
}
@Test
void existingRecordWithoutMobPreferenceDefaultsToDisabled() throws Exception {
UUID ownerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
Files.writeString(stateFile, "owners:\n " + ownerId + ":\n level: 1\n");
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
PocketBaseState loaded = repository.load().get(ownerId);
assertFalse(loaded.mobSpawningEnabled());
assertTrue(loaded.returnPortal().isEmpty());
}
}