feat(base): enforce configurable spawn distance
Release / release (push) Successful in 3m7s
CI / build (push) Successful in 1m43s

This commit is contained in:
dmg
2026-09-12 08:35:29 -04:00
parent 745d09e5a6
commit 8fc4075276
12 changed files with 746 additions and 19 deletions
@@ -37,17 +37,32 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
private final BaseStateManager stateManager;
private final AdminProgressionService progressionService;
private final PluginSettingsProvider settingsProvider;
private final ConfigWriter configWriter;
private boolean configSaving;
@FunctionalInterface
interface ConfigWriter {
void save(String yaml, java.util.function.Consumer<Exception> completed);
}
BaseAdminCommand(
JavaPlugin plugin,
BaseStateManager stateManager,
AdminProgressionService progressionService,
PluginSettingsProvider settingsProvider
) {
this(plugin, stateManager, progressionService, settingsProvider,
(yaml, completed) -> ConfigFileStore.saveAsync(plugin, yaml, completed));
}
BaseAdminCommand(JavaPlugin plugin, BaseStateManager stateManager,
AdminProgressionService progressionService, PluginSettingsProvider settingsProvider, ConfigWriter configWriter
) {
this.plugin = plugin;
this.stateManager = stateManager;
this.progressionService = progressionService;
this.settingsProvider = settingsProvider;
this.configWriter = configWriter;
}
@Override
@@ -318,9 +333,13 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin config <key> <integer>");
return true;
}
if (configSaving) {
sender.sendMessage(ChatColor.RED + "A configuration save is already in progress. Please retry after it finishes.");
return true;
}
String key = arguments[1];
Object previous = plugin.getConfig().get(key);
if (!(previous instanceof Number)) {
if (!(previous instanceof Number) && !key.equals("minimum-spawn-distance")) {
sender.sendMessage(ChatColor.RED + "Unknown numeric setting: " + key);
return true;
}
@@ -332,16 +351,29 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
PluginSettings updated = PluginSettingsValidator.validateMaterials(
PluginSettings.from(proposed)
);
plugin.getConfig().set(key, value);
var document = new org.bukkit.configuration.file.YamlConfiguration();
document.loadFromString(plugin.getConfig().saveToString());
document.set(key, value);
String yaml = document.saveToString();
configSaving = true;
try {
plugin.saveConfig();
configWriter.save(yaml, failure -> {
configSaving = false;
if (failure != null) {
configSaveFailed(sender, failure);
return;
}
plugin.getConfig().set(key, value);
settingsProvider.update(updated);
sender.sendMessage(ChatColor.GREEN + "Updated " + key + " to " + value
+ "; the change is active immediately.");
});
} catch (RuntimeException exception) {
plugin.getConfig().set(key, previous);
throw exception;
configSaving = false;
configSaveFailed(sender, exception);
}
settingsProvider.update(updated);
sender.sendMessage(ChatColor.GREEN + "Updated " + key + " to " + value
+ "; the change is active immediately.");
} catch (org.bukkit.configuration.InvalidConfigurationException exception) {
configSaveFailed(sender, exception);
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "The setting value must be an integer.");
} catch (IllegalArgumentException exception) {
@@ -350,6 +382,11 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
return true;
}
private void configSaveFailed(CommandSender sender, Exception failure) {
plugin.getLogger().warning("Could not save configuration: " + failure.getMessage());
sender.sendMessage(ChatColor.RED + "Could not save configuration. Active settings are unchanged; check the server log.");
}
@Override
public List<String> onTabComplete(
CommandSender sender,
@@ -368,9 +405,12 @@ final class BaseAdminCommand implements CommandExecutor, TabCompleter {
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("config")) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return plugin.getConfig().getValues(false).entrySet().stream()
.filter(entry -> entry.getValue() instanceof Number)
.map(java.util.Map.Entry::getKey)
return java.util.stream.Stream.concat(
plugin.getConfig().getValues(false).entrySet().stream()
.filter(entry -> entry.getValue() instanceof Number)
.map(java.util.Map.Entry::getKey),
java.util.stream.Stream.of("minimum-spawn-distance")
).distinct()
.filter(key -> key.toLowerCase(Locale.ROOT).startsWith(prefix))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
@@ -15,6 +15,11 @@ public final class BaseService {
this.settings = settings;
}
public int remainingSpawnDistance(BaseLocation center, double spawnX, double spawnZ) {
double distance = Math.hypot(center.x() - spawnX, center.z() - spawnZ);
return (int) Math.ceil(Math.max(0, settings.current().minimumSpawnDistance() - distance));
}
public boolean canSetBase(PlayerState player, Instant now) {
if (player.baseLevel() < 1) {
return false;
@@ -0,0 +1,62 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.function.Consumer;
import org.bukkit.plugin.IllegalPluginAccessException;
import org.bukkit.plugin.java.JavaPlugin;
/** Config persistence with observable failure; no Bukkit configuration objects cross threads. */
final class ConfigFileStore {
private final Path path;
ConfigFileStore(Path path) {
this.path = path.toAbsolutePath();
}
void save(String yaml) throws IOException {
if (Files.isDirectory(path)) {
throw new IOException("Config target is a directory");
}
Path temporary = Files.createTempFile(path.getParent(), "config-", ".yml.tmp");
try {
Files.writeString(temporary, yaml, StandardCharsets.UTF_8);
try {
Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
static void saveAsync(JavaPlugin plugin, String yaml, Consumer<Exception> completed) {
var scheduler = plugin.getServer().getScheduler();
Path path = plugin.getDataFolder().toPath().resolve("config.yml");
scheduler.runTaskAsynchronously(plugin, () -> {
Exception failure = null;
try {
new ConfigFileStore(path).save(yaml);
} catch (IOException | RuntimeException exception) {
failure = exception;
}
Exception result = failure;
if (plugin.isEnabled()) {
try {
scheduler.runTask(plugin, () -> completed.accept(result));
return;
} catch (IllegalPluginAccessException exception) {
// Disable raced completion: the file result is retained, never mutate disabled gameplay.
}
}
if (failure != null) {
plugin.getLogger().warning("Could not save configuration during disable: " + failure.getMessage());
}
});
}
}
@@ -11,6 +11,7 @@ public record PluginSettings(
int navigationUnlockBlocks,
int initialRadius,
int initialVerticalRange,
int minimumSpawnDistance,
int stoneExpansionBlocks,
int deepslateExpansionBlocks,
int obsidianExpansionBlocks,
@@ -104,6 +105,7 @@ public record PluginSettings(
}
requirePositive(initialRadius, "initial-radius");
requirePositive(initialVerticalRange, "initial-vertical-range");
requireNonNegative(minimumSpawnDistance, "minimum-spawn-distance");
requirePositive(stoneExpansionBlocks, "stone-expansion-blocks");
requirePositive(deepslateExpansionBlocks, "deepslate-expansion-blocks");
requirePositive(obsidianExpansionBlocks, "obsidian-expansion-blocks");
@@ -189,6 +191,7 @@ public record PluginSettings(
integer(values, "navigation-unlock-blocks", DEFAULT_NAVIGATION_UNLOCK_BLOCKS),
integer(values, "initial-radius", DEFAULT_INITIAL_RADIUS),
integer(values, "initial-vertical-range", DEFAULT_INITIAL_VERTICAL_RANGE),
exactInteger(values, "minimum-spawn-distance", 100),
integer(values, "stone-expansion-blocks", DEFAULT_STONE_EXPANSION_BLOCKS),
integer(values, "deepslate-expansion-blocks", DEFAULT_DEEPSLATE_EXPANSION_BLOCKS),
integer(values, "obsidian-expansion-blocks", DEFAULT_OBSIDIAN_EXPANSION_BLOCKS),
@@ -295,6 +298,22 @@ public record PluginSettings(
return value.trim().toUpperCase(java.util.Locale.ROOT);
}
private static int exactInteger(Map<String, ?> values, String key, int defaultValue) {
Object value = values.get(key);
if (value == null) {
return defaultValue;
}
if (!(value instanceof Number number)) {
throw new IllegalArgumentException(key + " must be an integer");
}
try {
// Validate before narrowing: Number.intValue/longValue can truncate or saturate.
return new java.math.BigDecimal(number.toString()).intValueExact();
} catch (NumberFormatException | ArithmeticException exception) {
throw new IllegalArgumentException(key + " must be a 32-bit integer", exception);
}
}
private static int integer(Map<String, ?> values, String key, int defaultValue) {
long value = longInteger(values, key, defaultValue);
if (value > Integer.MAX_VALUE || value < Integer.MIN_VALUE) {
@@ -66,6 +66,13 @@ final class SetBaseCommand implements CommandExecutor {
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
location.getBlockZ(), location.getYaw(), location.getPitch()
);
Location spawn = world.getSpawnLocation();
int remainingDistance = baseService.remainingSpawnDistance(base, spawn.getX(), spawn.getZ());
if (remainingDistance > 0) {
player.sendMessage(ChatColor.RED + "Your base is too close to spawn. Move at least "
+ remainingDistance + " more blocks away from spawn.");
return true;
}
stateManager.update(
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
);
+3
View File
@@ -7,6 +7,9 @@ navigation-unlock-blocks: 500
initial-radius: 10
initial-vertical-range: 25
relocation-cooldown-seconds: 86400
# Normal base placement only: horizontal center-to-current-world-spawn distance.
# Whole blocks, 0..2147483647 (32-bit integer); 0 disables. Equality is allowed.
minimum-spawn-distance: 100
# Base size
stone-expansion-blocks: 500
@@ -170,12 +170,10 @@ final class BaseAdminCommandTest {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
FileConfiguration configuration = new org.bukkit.configuration.file.YamlConfiguration();
configuration.set("spawnable-overlay-unlock-placements", 250);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.get("spawnable-overlay-unlock-placements")).thenReturn(250);
when(configuration.getValues(false)).thenReturn(Map.of(
"spawnable-overlay-unlock-placements", 250
));
java.util.List<String> saved = new java.util.ArrayList<>();
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
@@ -183,7 +181,8 @@ final class BaseAdminCommandTest {
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
settings
settings,
(yaml, completed) -> { saved.add(yaml); completed.accept(null); }
);
try (MockedStatic<PluginSettingsValidator> validator =
@@ -196,8 +195,9 @@ final class BaseAdminCommandTest {
});
}
verify(configuration).set("spawnable-overlay-unlock-placements", 500L);
verify(plugin).saveConfig();
assertEquals(500L, configuration.get("spawnable-overlay-unlock-placements"));
assertEquals(1, saved.size());
org.junit.jupiter.api.Assertions.assertTrue(saved.getFirst().contains("spawnable-overlay-unlock-placements: 500"));
assertEquals(500, settings.current().spawnableOverlayUnlockPlacements());
}
@@ -0,0 +1,104 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class ConfigFileStoreTest {
@TempDir Path directory;
private final List<Runnable> workers = new ArrayList<>(), completions = new ArrayList<>();
private final List<Exception> results = new ArrayList<>();
private final JavaPlugin plugin = mock(JavaPlugin.class);
private final BukkitScheduler scheduler = mock(BukkitScheduler.class);
private Path config;
@BeforeEach
void setup() throws Exception {
config = directory.resolve("config.yml");
Files.writeString(config, "minimum-spawn-distance: 100\n");
Server server = mock(Server.class);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(plugin.getDataFolder()).thenReturn(directory.toFile());
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(plugin.isEnabled()).thenReturn(true);
when(scheduler.runTaskAsynchronously(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
workers.add(call.getArgument(1));
return mock(BukkitTask.class);
});
when(scheduler.runTask(eq(plugin), any(Runnable.class))).thenAnswer(call -> {
completions.add(call.getArgument(1));
return mock(BukkitTask.class);
});
}
@Test
void fileWriteRunsOnWorkerAndAcknowledgementReturnsToMainThread() throws Exception {
Thread main = Thread.currentThread();
ConfigFileStore.saveAsync(plugin, "minimum-spawn-distance: 0\nfuture-setting: preserved\n", failure -> {
assertSame(main, Thread.currentThread());
results.add(failure);
});
assertEquals("minimum-spawn-distance: 100\n", Files.readString(config));
assertTrue(results.isEmpty());
assertEquals(1, workers.size());
CompletableFuture.runAsync(workers.removeFirst()).join();
assertTrue(Files.readString(config).contains("minimum-spawn-distance: 0"));
assertTrue(results.isEmpty(), "No gameplay acknowledgement on the I/O thread");
assertEquals(1, completions.size());
completions.removeFirst().run();
assertEquals(1, results.size());
assertNull(results.getFirst());
try (var files = Files.list(directory)) {
assertEquals(List.of(config), files.toList(), "No temporary files left after replacement");
}
}
@Test
void observableFailureLeavesExistingDirectoryContentsUntouched() throws Exception {
Files.delete(config);
Files.createDirectory(config);
Path sentinel = config.resolve("retained.txt");
Files.writeString(sentinel, "retained");
ConfigFileStore.saveAsync(plugin, "minimum-spawn-distance: 0\n", results::add);
CompletableFuture.runAsync(workers.removeFirst()).join();
assertTrue(results.isEmpty());
completions.removeFirst().run();
assertInstanceOf(java.io.IOException.class, results.getFirst());
assertEquals("retained", Files.readString(sentinel));
}
@Test
void disableDoesNotScheduleAnUpdateToDisabledGameplay() throws Exception {
ConfigFileStore.saveAsync(plugin, "minimum-spawn-distance: 0\n", results::add);
when(plugin.isEnabled()).thenReturn(false);
CompletableFuture.runAsync(workers.removeFirst()).join();
assertTrue(completions.isEmpty());
assertTrue(results.isEmpty());
assertEquals("minimum-spawn-distance: 0\n", Files.readString(config));
}
@Test
void disableRacingCompletionSchedulingKeepsSavedFileWithoutCallingBack() throws Exception {
when(scheduler.runTask(eq(plugin), any(Runnable.class)))
.thenThrow(new org.bukkit.plugin.IllegalPluginAccessException("disabled"));
ConfigFileStore.saveAsync(plugin, "minimum-spawn-distance: 0\n", results::add);
CompletableFuture.runAsync(workers.removeFirst()).join();
assertTrue(results.isEmpty());
assertEquals("minimum-spawn-distance: 0\n", Files.readString(config));
}
}
@@ -0,0 +1,158 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
final class SetBaseCommandTest {
@TempDir Path directory;
private final UUID playerId = UUID.randomUUID();
private final Instant now = Instant.ofEpochSecond(200_000);
private final World world = mock(World.class);
private final Player player = mock(Player.class);
private final PocketBaseController pockets = mock(PocketBaseController.class);
private BaseStateManager states;
private SetBaseCommand command;
@BeforeEach
void setUp() throws Exception {
states = new BaseStateManager(new YamlBaseStateRepository(directory.resolve("state.yml")),
Logger.getAnonymousLogger());
states.update(playerId, "Builder", state -> state.withAdministrativeLevels(
1, 0, 0, 0, 0, false, false, false));
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("current-world");
when(world.getSpawnLocation()).thenReturn(new Location(world, 200, 64, -300));
command = new SetBaseCommand(states, new BaseService(PluginSettings.from(Map.of())),
Clock.fixed(now, ZoneOffset.UTC), pockets);
}
@ParameterizedTest
@ValueSource(strings = {"setbase", "sethome"})
void rejectsNewBaseAtSpawnWithoutStateOrCooldownChanges(String label) throws Exception {
states.saveIfDirty();
PlayerState before = state();
when(player.getLocation()).thenReturn(new Location(world, 200, 250, -300));
assertTrue(command.onCommand(player, null, label, new String[0]));
assertSame(before, state());
assertEquals(before, reloadedState());
verify(player).sendMessage(ChatColor.RED
+ "Your base is too close to spawn. Move at least 100 more blocks away from spawn.");
verify(pockets, never()).deactivateForRelocation(playerId);
}
@ParameterizedTest
@CsvSource({"299.99, -300, 1", "260, -221, 1", "230, -260, 50", "199.9, -300, 99"})
void usesBlockCenterHorizontalDistanceAndCeilingWarning(double x, double z, int remaining) {
PlayerState before = state();
when(player.getLocation()).thenReturn(new Location(world, x, -60, z));
command.onCommand(player, null, "sethome", new String[0]);
assertSame(before, state());
verify(player).sendMessage(ChatColor.RED + "Your base is too close to spawn. Move at least "
+ remaining + " more blocks away from spawn.");
}
@ParameterizedTest
@CsvSource({"setbase, 300, -300", "sethome, 260, -220", "setbase, 200, -401"})
void acceptsExactMinimumAndBeyondIgnoringHeightAndRadius(String label, int x, int z)
throws Exception {
when(player.getLocation()).thenReturn(new Location(world, x, -60, z, 45, 30));
command.onCommand(player, null, label, new String[0]);
assertEquals(new BaseLocation(world.getUID(), "current-world", x, -60, z, 45, 30),
state().base().orElseThrow());
assertEquals(now, state().lastBaseSet().orElseThrow());
assertEquals(state(), reloadedState());
verify(pockets).deactivateForRelocation(playerId);
}
@Test
void rejectsRelocationWithoutExtendingCooldownAndKeepsExistingBaseWhenSpawnMoves()
throws Exception {
states.update(playerId, "Builder", state -> state.withBase(
new BaseLocation(world.getUID(), "current-world", 300, 64, -300, 0, 0), Instant.EPOCH));
states.saveIfDirty();
PlayerState before = state();
when(world.getSpawnLocation()).thenReturn(new Location(world, 300, 100, -300));
when(player.getLocation()).thenReturn(new Location(world, 350, 64, -300));
assertSame(before, state());
command.onCommand(player, null, "setbase", new String[0]);
assertSame(before, state());
assertEquals(before, reloadedState());
verify(player).sendMessage(ChatColor.RED
+ "Your base is too close to spawn. Move at least 50 more blocks away from spawn.");
verify(pockets, never()).deactivateForRelocation(playerId);
}
@Test
void unlockRestrictionStillPrecedesPlacementCheck() {
states.update(playerId, "Builder", ignored -> PlayerState.newPlayer(playerId, "Builder"));
PlayerState before = state();
command.onCommand(player, null, "setbase", new String[0]);
assertSame(before, state());
verify(player).sendMessage(ChatColor.RED
+ "Base I is locked. Break grass blocks or dirt to unlock it.");
verify(player, never()).getLocation();
}
@Test
void activeCooldownIsNotExtendedAndStillPrecedesPlacementCheck() {
states.update(playerId, "Builder", state -> state.withBase(
new BaseLocation(world.getUID(), "current-world", 300, 64, -300, 0, 0), now));
PlayerState before = state();
command.onCommand(player, null, "sethome", new String[0]);
assertSame(before, state());
verify(player, never()).getLocation();
}
@Test
void pocketWorldRestrictionStillPrecedesSpawnCheck() {
when(player.getLocation()).thenReturn(new Location(world, 300, 64, -300));
when(pockets.isPocketWorld(world.getUID())).thenReturn(true);
PlayerState before = state();
command.onCommand(player, null, "sethome", new String[0]);
assertSame(before, state());
verify(player).sendMessage(ChatColor.RED + "You cannot set your normal base inside a Pocket Base.");
verify(world, never()).getSpawnLocation();
verify(pockets, never()).deactivateForRelocation(playerId);
}
private PlayerState state() {
return states.player(playerId, "Builder");
}
private PlayerState reloadedState() throws Exception {
return new YamlBaseStateRepository(directory.resolve("state.yml")).load().players().get(playerId);
}
}
@@ -0,0 +1,278 @@
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.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
final class SpawnDistanceAdminCommandTest {
private static final String KEY = "minimum-spawn-distance";
@TempDir Path directory;
private Path configFile;
private JavaPlugin plugin;
private final CommandSender admin = mock(CommandSender.class);
private final BaseStateManager untouchedStates = mock(BaseStateManager.class);
private PluginSettingsProvider settings;
private BaseAdminCommand command;
@BeforeEach
void setUp() throws Exception {
configFile = directory.resolve("config.yml");
// A real legacy file without the new key or resource defaults.
Files.writeString(configFile, "initial-radius: 10\nfuture-setting: preserved\n");
plugin = fileBackedPlugin();
settings = new PluginSettingsProvider(PluginSettings.from(plugin.getConfig().getValues(false)));
when(admin.hasPermission("spigotbase.admin")).thenReturn(true);
command = new BaseAdminCommand(plugin, untouchedStates,
new AdminProgressionService(settings), settings, this::saveFile);
}
@ParameterizedTest
@ValueSource(strings = {"baseadmin", "homeadmin"})
void updatesLegacyConfigConfirmsAndSurvivesFreshPluginAndProvider(String alias) throws Exception {
assertEquals(100, settings.current().minimumSpawnDistance());
update(alias, "250");
assertEquals(250, settings.current().minimumSpawnDistance());
verify(admin).sendMessage(ChatColor.GREEN + "Updated minimum-spawn-distance to 250"
+ "; the change is active immediately.");
JavaPlugin restarted = fileBackedPlugin();
PluginSettingsProvider restored = new PluginSettingsProvider(
PluginSettings.from(restarted.getConfig().getValues(false)));
assertEquals(250, restored.current().minimumSpawnDistance());
assertEquals("preserved", restarted.getConfig().getString("future-setting"));
verifyNoInteractions(untouchedStates);
}
@ParameterizedTest
@ValueSource(strings = {"0", "2147483647"})
void persistsBothRepresentableEndpoints(String value) throws Exception {
update("homeadmin", value);
assertEquals(Integer.parseInt(value), settings.current().minimumSpawnDistance());
assertEquals(Integer.parseInt(value), PluginSettings.from(
fileBackedPlugin().getConfig().getValues(false)).minimumSpawnDistance());
}
@ParameterizedTest
@ValueSource(strings = {"baseadmin", "homeadmin"})
void completionIsPermissionAwareAndIncludesLegacyMissingKey(String alias) throws Exception {
assertEquals(List.of(KEY), command.onTabComplete(admin, null, alias,
new String[] {"config", "MINIMUM"}));
when(admin.hasPermission("spigotbase.admin")).thenReturn(false);
assertTrue(command.onTabComplete(admin, null, alias,
new String[] {"config", ""}).isEmpty());
String before = Files.readString(configFile);
PluginSettings previous = settings.current();
command.onCommand(admin, null, alias, new String[] {"config", KEY, "0"});
assertSame(previous, settings.current());
assertEquals(before, Files.readString(configFile));
assertFalse(plugin.getConfig().contains(KEY));
verify(plugin, never()).saveConfig();
verifyNoInteractions(untouchedStates);
}
@ParameterizedTest
@ValueSource(strings = {"-1", "1.5", "NaN", "Infinity", "2147483648", "4294967296",
"9223372036854775807", "18446744073709551616", "1e2", "", "hello"})
void invalidValuesChangeNeitherLiveSettingsNorConfigFile(String value) throws Exception {
update("baseadmin", "125");
assertEquals(125, settings.current().minimumSpawnDistance());
PluginSettings previous = settings.current();
String before = Files.readString(configFile);
Map<String, Object> beforeValues = plugin.getConfig().getValues(false);
update("homeadmin", value);
assertSame(previous, settings.current());
assertEquals(beforeValues, plugin.getConfig().getValues(false));
assertEquals(before, Files.readString(configFile));
verifyNoInteractions(untouchedStates);
}
@Test
void failedConfigWriteDoesNotApplyOrConfirmAnUnsavedValue() throws Exception {
PluginSettings previous = settings.current();
Map<String, Object> previousValues = plugin.getConfig().getValues(false);
Files.delete(configFile);
Files.createDirectory(configFile); // Actual I/O failure, including when tests run as root.
update("baseadmin", "0");
verify(admin, never()).sendMessage(org.mockito.ArgumentMatchers.contains("Updated minimum-spawn-distance"));
assertSame(previous, settings.current());
assertEquals(previousValues, plugin.getConfig().getValues(false));
verifyNoInteractions(untouchedStates);
}
@Test
void pendingSaveKeepsLiveStateAndRejectsOverlappingUpdatesUntilCompletion() throws Exception {
var callbacks = new java.util.ArrayList<java.util.function.Consumer<Exception>>();
command = new BaseAdminCommand(plugin, untouchedStates,
new AdminProgressionService(settings), settings, (yaml, completed) -> callbacks.add(completed));
PluginSettings previous = settings.current();
String fileBefore = Files.readString(configFile);
update("baseadmin", "0");
assertSame(previous, settings.current());
assertFalse(plugin.getConfig().contains(KEY));
verify(admin, never()).sendMessage(org.mockito.ArgumentMatchers.contains("Updated minimum-spawn-distance"));
update("homeadmin", "250");
assertEquals(1, callbacks.size(), "Do not enqueue a stale whole-config snapshot");
verify(admin).sendMessage(ChatColor.RED + "A configuration save is already in progress. Please retry after it finishes.");
callbacks.getFirst().accept(new java.io.IOException("disk full"));
assertSame(previous, settings.current());
assertEquals(fileBefore, Files.readString(configFile));
update("homeadmin", "250");
assertEquals(2, callbacks.size(), "A failed save must release the in-flight guard");
callbacks.getLast().accept(null);
assertEquals(250, settings.current().minimumSpawnDistance());
assertEquals(250L, plugin.getConfig().get(KEY));
}
@Test
void rejectedWorkerSubmissionLeavesStateUnchangedAndAllowsRetry() {
int[] attempts = {0};
command = new BaseAdminCommand(plugin, untouchedStates,
new AdminProgressionService(settings), settings, (yaml, completed) -> {
if (attempts[0]++ == 0) {
throw new java.util.concurrent.RejectedExecutionException("worker unavailable");
}
saveFile(yaml, completed);
});
PluginSettings previous = settings.current();
update("baseadmin", "0");
assertSame(previous, settings.current());
verify(admin, never()).sendMessage(org.mockito.ArgumentMatchers.contains("Updated minimum-spawn-distance"));
update("baseadmin", "0");
assertEquals(0, settings.current().minimumSpawnDistance());
}
@Test
void packagedConfigDeclaresDefault() throws Exception {
try (var stream = getClass().getClassLoader().getResourceAsStream("config.yml")) {
var yaml = YamlConfiguration.loadConfiguration(new java.io.InputStreamReader(
java.util.Objects.requireNonNull(stream), java.nio.charset.StandardCharsets.UTF_8));
assertEquals(100, yaml.getInt(KEY));
assertEquals(100, PluginSettings.from(yaml.getValues(false)).minimumSpawnDistance());
}
}
@Test
void changesAffectOnlySubsequentPlacementsIncludingDisableAndOtherWorlds() throws Exception {
BaseStateManager states = new BaseStateManager(
new YamlBaseStateRepository(directory.resolve("state.yml")), Logger.getAnonymousLogger());
UUID id = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Builder");
World world = world("first", 0, 0);
when(player.getLocation()).thenReturn(new Location(world, 150, 64, 0));
states.update(id, "Builder", state -> state.withBaseLevel(1));
SetBaseCommand placement = new SetBaseCommand(states, new BaseService(settings),
Clock.fixed(Instant.EPOCH, ZoneOffset.UTC));
update("baseadmin", "200");
placement.onCommand(player, null, "setbase", new String[0]);
assertTrue(states.player(id, "Builder").base().isEmpty());
update("homeadmin", "100");
placement.onCommand(player, null, "sethome", new String[0]);
PlayerState established = states.player(id, "Builder");
assertEquals(150, established.base().orElseThrow().x());
update("baseadmin", "400");
assertSame(established, states.player(id, "Builder"));
// After the normal relocation cooldown, the same global setting applies in another world.
World otherWorld = world("second", -500, 200);
when(player.getLocation()).thenReturn(new Location(otherWorld, -350, -60, 200));
placement = new SetBaseCommand(states, new BaseService(settings),
Clock.fixed(Instant.ofEpochSecond(86_400), ZoneOffset.UTC));
placement.onCommand(player, null, "sethome", new String[0]);
assertSame(established, states.player(id, "Builder"));
verify(player).sendMessage(ChatColor.RED
+ "Your base is too close to spawn. Move at least 250 more blocks away from spawn.");
update("homeadmin", "0");
assertSame(established, states.player(id, "Builder"));
when(player.getLocation()).thenReturn(new Location(otherWorld, -500, 64, 200));
placement.onCommand(player, null, "setbase", new String[0]);
assertEquals(otherWorld.getUID(), states.player(id, "Builder").base().orElseThrow().worldId());
assertEquals(-500, states.player(id, "Builder").base().orElseThrow().x());
assertEquals(0, PluginSettings.from(fileBackedPlugin().getConfig().getValues(false))
.minimumSpawnDistance());
verifyNoInteractions(untouchedStates);
}
private void update(String alias, String value) {
// Material validation requires server registries, unrelated to the numeric setting under test.
// Keep all numeric parsing, validation, command mutation, and YAML persistence real.
try (MockedStatic<PluginSettingsValidator> validator = mockStatic(PluginSettingsValidator.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(any(PluginSettings.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
assertTrue(command.onCommand(admin, null, alias, new String[] {"config", KEY, value}));
}
}
private void saveFile(String yaml, java.util.function.Consumer<Exception> completed) {
Exception failure = null;
try {
new ConfigFileStore(configFile).save(yaml);
} catch (java.io.IOException exception) {
failure = exception;
}
completed.accept(failure);
}
private JavaPlugin fileBackedPlugin() throws Exception {
JavaPlugin result = mock(JavaPlugin.class);
// Bukkit's classloader normally initializes this file. Exercise its actual load/reload methods;
// the injected ConfigFileStore writes real files and reports failures rather than swallowing them.
Field field = JavaPlugin.class.getDeclaredField("configFile");
field.setAccessible(true);
field.set(result, configFile.toFile());
Field logger = JavaPlugin.class.getDeclaredField("logger");
logger.setAccessible(true);
logger.set(result, Logger.getAnonymousLogger());
doCallRealMethod().when(result).getLogger();
doCallRealMethod().when(result).getConfig();
doCallRealMethod().when(result).reloadConfig();
doCallRealMethod().when(result).saveConfig();
return result;
}
private static World world(String name, int x, int z) {
World result = mock(World.class);
when(result.getUID()).thenReturn(UUID.randomUUID());
when(result.getName()).thenReturn(name);
when(result.getSpawnLocation()).thenReturn(new Location(result, x, 64, z));
return result;
}
}
@@ -0,0 +1,46 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
final class SpawnDistanceSettingsTest {
private static final String KEY = "minimum-spawn-distance";
private final BaseLocation center = new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0);
@Test
void legacyMissingKeyDefaultsToOneHundred() {
assertEquals(100, new BaseService(PluginSettings.from(Map.of()))
.remainingSpawnDistance(center, 0, 0));
}
@ParameterizedTest
@ValueSource(ints = {0, 1, 250, Integer.MAX_VALUE})
void acceptsEntireNonnegativeIntRangeAndUsesItForPlacement(int minimum) {
BaseService service = new BaseService(PluginSettings.from(Map.of(KEY, minimum)));
assertEquals(minimum, service.remainingSpawnDistance(center, 0, 0));
assertEquals(0, service.remainingSpawnDistance(center, minimum, 0));
}
@ParameterizedTest
@MethodSource("invalidValues")
void rejectsInvalidValuesWithoutTruncationOrSaturation(Object value) {
assertThrows(IllegalArgumentException.class, () -> PluginSettings.from(Map.of(KEY, value)));
}
static Stream<Object> invalidValues() {
return Stream.of(-1, 0.5, Double.NaN, Double.POSITIVE_INFINITY,
2_147_483_648L, Long.MAX_VALUE, 1e30,
new BigInteger("18446744073709551616"), new BigDecimal("0.1"),
"100", true);
}
}