feat(pocket-base): add mob spawning toggle
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m28s

This commit is contained in:
dmg
2026-08-24 09:57:48 -04:00
parent 11e3ad1720
commit 23e8fcec6e
13 changed files with 376 additions and 17 deletions
@@ -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>]");
}
@@ -60,6 +60,27 @@ final class PocketBaseManager {
}
}
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);
}
@@ -6,7 +6,8 @@ import java.util.UUID;
public record PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
Optional<PocketPortalLocation> entrance,
boolean mobSpawningEnabled
) {
public PocketBaseState {
if (ownerId == null) {
@@ -21,19 +22,31 @@ public record PocketBaseState(
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
) {
this(ownerId, level, entrance, false);
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(ownerId, 0, Optional.empty());
return new PocketBaseState(ownerId, 0, Optional.empty(), false);
}
public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(ownerId, newLevel, entrance);
return new PocketBaseState(ownerId, newLevel, entrance, mobSpawningEnabled);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(ownerId, level, Optional.of(portal));
return new PocketBaseState(ownerId, level, Optional.of(portal), mobSpawningEnabled);
}
public PocketBaseState withoutEntrance() {
return new PocketBaseState(ownerId, level, Optional.empty());
return new PocketBaseState(ownerId, level, Optional.empty(), mobSpawningEnabled);
}
public PocketBaseState withMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(ownerId, level, entrance, 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);
}
@@ -90,7 +94,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 +105,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,8 @@ public final class YamlPocketBaseRepository {
PocketBaseState state = new PocketBaseState(
ownerId,
level,
loadPortal(yaml, path + ".entrance")
loadPortal(yaml, path + ".entrance"),
yaml.getBoolean(path + ".mob-spawning-enabled", false)
);
states.put(ownerId, state);
} catch (IllegalArgumentException ignored) {
@@ -61,6 +62,7 @@ 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));
}
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
@@ -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();
@@ -0,0 +1,66 @@
package games.dmg.spigotbase;
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 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);
}
}
@@ -9,6 +9,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 +42,30 @@ 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 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,9 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
@@ -22,7 +24,8 @@ final class YamlPocketBaseRepositoryTest {
7,
Optional.of(new PocketPortalLocation(
worldId, "world", 10, 65, -4, PocketPortalAxis.X
))
)),
true
);
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml")
@@ -32,4 +35,14 @@ 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);
assertFalse(repository.load().get(ownerId).mobSpawningEnabled());
}
}