feat(spawn): configure per-world spawn areas
This commit is contained in:
@@ -28,6 +28,14 @@ cooldowns:
|
||||
|
||||
Durable player and per-world state is stored in `plugins/TriggerSpawn/state.yml`.
|
||||
|
||||
Each world initially uses its native spawn and a 20-block circular radius. An operator can configure the current world with:
|
||||
|
||||
```text
|
||||
/spawnadmin set
|
||||
/spawnadmin distance <blocks>
|
||||
/spawnadmin info
|
||||
```
|
||||
|
||||
For a local versioned build:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -28,6 +28,7 @@ dependencies {
|
||||
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
testImplementation("org.yaml:snakeyaml:2.4")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
@@ -33,3 +33,9 @@ description: Chronological record of significant Trigger Spawn design decisions.
|
||||
- Added atomic YAML persistence for UUID-based player access state and per-world spawn settings.
|
||||
- Invalid persisted access values fail closed, while invalid required configuration disables the plugin rather than allowing partial startup.
|
||||
- Added friendly duration formatting and the administrative permission declaration.
|
||||
|
||||
## 2026-08-08 — Per-world spawn areas
|
||||
|
||||
- Added `/spawnadmin set`, `distance`, and `info` for player-operated configuration of the current world.
|
||||
- Worlds default to their native spawn and an independently persisted 20-block radius.
|
||||
- Circular target sampling is uniform by area, preserves configured facing, and supports a zero-block radius.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-003: Configure each world's spawn area"
|
||||
description: Let administrators define an independent center and circular safe-spawn radius for every world.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Configure each world's spawn area
|
||||
@@ -11,17 +11,17 @@ As a **server administrator**, I want to configure the spawn area independently
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every world has an independent spawn center and maximum spawn distance.
|
||||
- [ ] Until an administrator sets a custom center, a world uses its native world spawn point.
|
||||
- [ ] `/spawnadmin set` stores the executing administrator's current world, coordinates, yaw, and pitch as that world's center and facing direction.
|
||||
- [ ] `/spawnadmin distance <blocks>` sets the current world's maximum spawn distance in blocks.
|
||||
- [ ] `/spawnadmin info` displays the current world's effective center, whether it is native or custom, and maximum distance.
|
||||
- [ ] The default maximum spawn distance is 20 blocks.
|
||||
- [ ] The spawn area is circular, and random horizontal points are sampled uniformly by area within its radius.
|
||||
- [ ] A maximum distance of `0` targets the configured center and adjusts it to the nearest safe block.
|
||||
- [ ] Negative, non-numeric, or otherwise invalid distances are rejected with an explanatory message.
|
||||
- [ ] Random destinations use the center's saved yaw and pitch.
|
||||
- [ ] Per-world centers and distances survive server and plugin restarts.
|
||||
- [x] Every world has an independent spawn center and maximum spawn distance.
|
||||
- [x] Until an administrator sets a custom center, a world uses its native world spawn point.
|
||||
- [x] `/spawnadmin set` stores the executing administrator's current world, coordinates, yaw, and pitch as that world's center and facing direction.
|
||||
- [x] `/spawnadmin distance <blocks>` sets the current world's maximum spawn distance in blocks.
|
||||
- [x] `/spawnadmin info` displays the current world's effective center, whether it is native or custom, and maximum distance.
|
||||
- [x] The default maximum spawn distance is 20 blocks.
|
||||
- [x] The spawn area is circular, and random horizontal points are sampled uniformly by area within its radius.
|
||||
- [x] A maximum distance of `0` targets the configured center and adjusts it to the nearest safe block.
|
||||
- [x] Negative, non-numeric, or otherwise invalid distances are rejected with an explanatory message.
|
||||
- [x] Random destinations use the center's saved yaw and pitch.
|
||||
- [x] Per-world centers and distances survive server and plugin restarts.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -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