feat(spawn): configure per-world spawn areas
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package games.dmg.triggerspawn;
|
||||
|
||||
import java.util.random.RandomGenerator;
|
||||
|
||||
final class CircularSpawnSampler {
|
||||
private CircularSpawnSampler() {
|
||||
}
|
||||
|
||||
static SpawnLocation sample(
|
||||
SpawnLocation center, int maxDistance, RandomGenerator random) {
|
||||
if (maxDistance < 0) {
|
||||
throw new IllegalArgumentException("maximum distance cannot be negative");
|
||||
}
|
||||
if (maxDistance == 0) {
|
||||
return center;
|
||||
}
|
||||
double radius = maxDistance * Math.sqrt(random.nextDouble());
|
||||
double angle = random.nextDouble() * Math.PI * 2;
|
||||
return new SpawnLocation(
|
||||
center.x() + radius * Math.cos(angle),
|
||||
center.y(),
|
||||
center.z() + radius * Math.sin(angle),
|
||||
center.yaw(),
|
||||
center.pitch());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package games.dmg.triggerspawn;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
final class SpawnAdminCommand implements CommandExecutor, TabCompleter {
|
||||
private final SpawnAreaService spawnAreas;
|
||||
|
||||
SpawnAdminCommand(SpawnAreaService spawnAreas) {
|
||||
this.spawnAreas = spawnAreas;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(ChatColor.RED + "This spawn-area command must be run by a player.");
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 0) {
|
||||
sendUsage(player, label);
|
||||
return true;
|
||||
}
|
||||
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||
case "set" -> setCenter(player, arguments);
|
||||
case "distance" -> setDistance(player, arguments);
|
||||
case "info" -> showInfo(player, arguments);
|
||||
default -> {
|
||||
sendUsage(player, label);
|
||||
yield true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender, Command command, String alias, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
return List.of();
|
||||
}
|
||||
String prefix = arguments[0].toLowerCase(Locale.ROOT);
|
||||
return List.of("set", "distance", "info").stream()
|
||||
.filter(value -> value.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean setCenter(Player player, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: /spawnadmin set");
|
||||
return true;
|
||||
}
|
||||
Location location = player.getLocation();
|
||||
SpawnLocation center = new SpawnLocation(
|
||||
location.getX(),
|
||||
location.getY(),
|
||||
location.getZ(),
|
||||
location.getYaw(),
|
||||
location.getPitch());
|
||||
try {
|
||||
spawnAreas.setCenter(
|
||||
player.getWorld().getUID(), player.getWorld().getName(), center);
|
||||
player.sendMessage(ChatColor.GREEN + "Spawn center set for "
|
||||
+ player.getWorld().getName() + ".");
|
||||
} catch (IOException exception) {
|
||||
player.sendMessage(ChatColor.RED + "Could not save the spawn center.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean setDistance(Player player, String[] arguments) {
|
||||
if (arguments.length != 2) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: /spawnadmin distance <blocks>");
|
||||
return true;
|
||||
}
|
||||
int distance;
|
||||
try {
|
||||
distance = Integer.parseInt(arguments[1]);
|
||||
if (distance < 0) {
|
||||
throw new NumberFormatException("negative distance");
|
||||
}
|
||||
} catch (NumberFormatException exception) {
|
||||
player.sendMessage(ChatColor.RED + "Distance must be a non-negative whole number of blocks.");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
World world = player.getWorld();
|
||||
spawnAreas.setMaxDistance(world.getUID(), world.getName(), distance);
|
||||
player.sendMessage(ChatColor.GREEN + "Maximum spawn distance set to "
|
||||
+ distance + " blocks for " + world.getName() + ".");
|
||||
} catch (IOException exception) {
|
||||
player.sendMessage(ChatColor.RED + "Could not save the spawn distance.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean showInfo(Player player, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: /spawnadmin info");
|
||||
return true;
|
||||
}
|
||||
World world = player.getWorld();
|
||||
WorldSpawnState settings = spawnAreas.settings(world.getUID(), world.getName());
|
||||
Location nativeSpawn = world.getSpawnLocation();
|
||||
SpawnLocation nativeCenter = new SpawnLocation(
|
||||
nativeSpawn.getX(),
|
||||
nativeSpawn.getY(),
|
||||
nativeSpawn.getZ(),
|
||||
nativeSpawn.getYaw(),
|
||||
nativeSpawn.getPitch());
|
||||
SpawnLocation center = settings.customCenter().orElse(nativeCenter);
|
||||
String source = settings.customCenter().isPresent() ? "custom" : "native";
|
||||
player.sendMessage(ChatColor.GOLD + "Spawn area for " + world.getName() + ": "
|
||||
+ ChatColor.YELLOW + source + " center at "
|
||||
+ format(center.x()) + ", " + format(center.y()) + ", " + format(center.z())
|
||||
+ "; radius " + settings.maxDistance() + " blocks.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String format(double coordinate) {
|
||||
return String.format(Locale.ROOT, "%.1f", coordinate);
|
||||
}
|
||||
|
||||
private static void sendUsage(CommandSender sender, String label) {
|
||||
sender.sendMessage(ChatColor.YELLOW + "Usage: /" + label + " <set|distance|info>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package games.dmg.triggerspawn;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class SpawnAreaService {
|
||||
private final SpawnStateManager stateManager;
|
||||
|
||||
SpawnAreaService(SpawnStateManager stateManager) {
|
||||
this.stateManager = stateManager;
|
||||
}
|
||||
|
||||
WorldSpawnState settings(UUID worldId, String latestName) {
|
||||
return stateManager.world(worldId, latestName);
|
||||
}
|
||||
|
||||
void setCenter(UUID worldId, String latestName, SpawnLocation center) throws IOException {
|
||||
WorldSpawnState current = settings(worldId, latestName);
|
||||
stateManager.putWorld(new WorldSpawnState(
|
||||
worldId, latestName, Optional.of(center), current.maxDistance()));
|
||||
}
|
||||
|
||||
void setMaxDistance(UUID worldId, String latestName, int maxDistance) throws IOException {
|
||||
WorldSpawnState current = settings(worldId, latestName);
|
||||
stateManager.putWorld(new WorldSpawnState(
|
||||
worldId, latestName, current.customCenter(), maxDistance));
|
||||
}
|
||||
|
||||
SpawnLocation effectiveCenter(
|
||||
UUID worldId, String latestName, SpawnLocation nativeSpawn) {
|
||||
return settings(worldId, latestName).customCenter().orElse(nativeSpawn);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package games.dmg.triggerspawn;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
/** Entry point for the Trigger Spawn Spigot plugin. */
|
||||
@@ -17,7 +18,14 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
|
||||
YamlSpawnStateRepository repository =
|
||||
new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml"));
|
||||
stateManager = SpawnStateManager.load(repository);
|
||||
} catch (IllegalArgumentException | IOException exception) {
|
||||
SpawnAdminCommand executor = new SpawnAdminCommand(new SpawnAreaService(stateManager));
|
||||
PluginCommand adminCommand = getCommand("spawnadmin");
|
||||
if (adminCommand == null) {
|
||||
throw new IllegalStateException("spawnadmin is missing from plugin.yml");
|
||||
}
|
||||
adminCommand.setExecutor(executor);
|
||||
adminCommand.setTabCompleter(executor);
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Trigger Spawn could not initialize safely", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package games.dmg.triggerspawn;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.random.RandomGenerator;
|
||||
import java.util.random.RandomGeneratorFactory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class CircularSpawnSamplerTest {
|
||||
@Test
|
||||
void samplesUniformlyByAreaInsideCircleAndPreservesFacing() {
|
||||
SpawnLocation center = new SpawnLocation(100, 64, -50, 135, 12);
|
||||
RandomGenerator random = RandomGeneratorFactory.of("L64X128MixRandom").create(42);
|
||||
double totalRadius = 0;
|
||||
int samples = 10_000;
|
||||
|
||||
for (int index = 0; index < samples; index++) {
|
||||
SpawnLocation result = CircularSpawnSampler.sample(center, 20, random);
|
||||
double radius = Math.hypot(result.x() - center.x(), result.z() - center.z());
|
||||
assertTrue(radius <= 20);
|
||||
assertEquals(64, result.y());
|
||||
assertEquals(135, result.yaw());
|
||||
assertEquals(12, result.pitch());
|
||||
totalRadius += radius;
|
||||
}
|
||||
|
||||
assertEquals(40.0 / 3.0, totalRadius / samples, 0.25);
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroDistanceReturnsCenter() {
|
||||
SpawnLocation center = new SpawnLocation(1, 2, 3, 4, 5);
|
||||
assertEquals(center, CircularSpawnSampler.sample(center, 0, RandomGenerator.getDefault()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package games.dmg.triggerspawn;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class SpawnAdminCommandTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void setAndDistanceConfigureThePlayersCurrentWorld() throws Exception {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
Player player = mock(Player.class);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(new Location(world, 4.5, 70, -9.5, 45, 8));
|
||||
SpawnStateManager manager = SpawnStateManager.load(
|
||||
new YamlSpawnStateRepository(temporaryDirectory.resolve("state.yml")));
|
||||
SpawnAreaService service = new SpawnAreaService(manager);
|
||||
SpawnAdminCommand command = new SpawnAdminCommand(service);
|
||||
Command bukkitCommand = mock(Command.class);
|
||||
|
||||
command.onCommand(player, bukkitCommand, "spawnadmin", new String[] {"set"});
|
||||
command.onCommand(player, bukkitCommand, "spawnadmin", new String[] {"distance", "12"});
|
||||
|
||||
WorldSpawnState configured = service.settings(worldId, "world");
|
||||
assertEquals(new SpawnLocation(4.5, 70, -9.5, 45, 8), configured.customCenter().orElseThrow());
|
||||
assertEquals(12, configured.maxDistance());
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains("Spawn center set"));
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.contains("12 blocks"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package games.dmg.triggerspawn;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class SpawnAreaServiceTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void configuresEachWorldIndependentlyAndPersistsIt() throws Exception {
|
||||
UUID firstWorld = UUID.randomUUID();
|
||||
UUID secondWorld = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||
SpawnStateManager manager = SpawnStateManager.load(new YamlSpawnStateRepository(stateFile));
|
||||
SpawnAreaService service = new SpawnAreaService(manager);
|
||||
SpawnLocation center = new SpawnLocation(10.5, 64, -4.5, 180, 5);
|
||||
|
||||
service.setCenter(firstWorld, "world", center);
|
||||
service.setMaxDistance(firstWorld, "world", 35);
|
||||
|
||||
assertEquals(center, service.settings(firstWorld, "world").customCenter().orElseThrow());
|
||||
assertEquals(35, service.settings(firstWorld, "world").maxDistance());
|
||||
assertTrue(service.settings(secondWorld, "nether").customCenter().isEmpty());
|
||||
assertEquals(20, service.settings(secondWorld, "nether").maxDistance());
|
||||
|
||||
SpawnAreaService reloaded = new SpawnAreaService(
|
||||
SpawnStateManager.load(new YamlSpawnStateRepository(stateFile)));
|
||||
assertEquals(center, reloaded.settings(firstWorld, "world").customCenter().orElseThrow());
|
||||
assertEquals(35, reloaded.settings(firstWorld, "world").maxDistance());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user