feat(base): enforce configurable spawn distance
This commit is contained in:
@@ -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