feat(base): enforce configurable spawn distance
This commit is contained in:
@@ -43,6 +43,8 @@ The plugin JAR is written to `build/libs/`.
|
||||
|
||||
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, marks unavailable controls as locked, and provides Back buttons from nested screens. `/basesettings status` retains the detailed chat report.
|
||||
|
||||
`/setbase` and `/sethome` require the proposed normal base center (the player's block coordinates) to be at least 100 blocks horizontally from the current world's spawn by default. Height and base radius do not affect this check; exactly the minimum is allowed. Rejection reports the remaining distance rounded up and leaves the existing base and relocation cooldown unchanged. This is a placement-only rule: existing bases are never rechecked when players move or spawn changes, and Pocket Base restrictions are unchanged.
|
||||
|
||||
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
|
||||
|
||||
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
|
||||
@@ -66,11 +68,14 @@ The `spigotbase.admin` permission is granted to server operators by default. `/h
|
||||
/baseadmin setsetting <player> spawnable <enable|disable>
|
||||
/baseadmin config <numeric-key> <value>
|
||||
# e.g. /baseadmin config spawnable-overlay-unlock-placements 250
|
||||
/baseadmin config minimum-spawn-distance 100
|
||||
/baseadmin clearcooldown <player> [personal|visitor|all]
|
||||
/baseadmin reset <player> <size|flight|warmup|cooldown>
|
||||
/baseadmin reset <player> all confirm
|
||||
```
|
||||
|
||||
`minimum-spawn-distance` is global and defaults to 100 blocks, including legacy configs without the key. It accepts whole numbers from `0` through `2147483647` (the same 32-bit representation used for spatial settings); overflow and invalid values are rejected without changes. `0` disables the spawn-distance restriction. Successful admin updates are confirmed and saved to `config.yml` for restarts, and affect only subsequent normal base placements—not existing bases. Numeric config writes run off the server tick thread using an isolated YAML snapshot and atomic replacement where supported. Live settings and the success reply change only after the write succeeds; failure leaves live settings unchanged. While a save is pending, another config update receives a retry message rather than overwriting settings with a stale snapshot. `/homeadmin` supports the same command and permission-aware key completion.
|
||||
|
||||
Default thresholds and durations are documented in `src/main/resources/config.yml`. Durable UUID-keyed state is stored in `plugins/SpigotBase/state.yml` using atomic replacement where supported.
|
||||
|
||||
## Releases
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user