feat(spawn): configure per-world spawn areas
Release / release (push) Failing after 12s
CI / build (push) Successful in 2m13s

This commit is contained in:
dmg
2026-08-08 13:18:39 -04:00
parent e646d12863
commit 18dfa058e5
11 changed files with 349 additions and 13 deletions
+8
View File
@@ -28,6 +28,14 @@ cooldowns:
Durable player and per-world state is stored in `plugins/TriggerSpawn/state.yml`. 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: For a local versioned build:
```bash ```bash
+1
View File
@@ -28,6 +28,7 @@ dependencies {
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
testImplementation("org.yaml:snakeyaml:2.4") testImplementation("org.yaml:snakeyaml:2.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
+6
View File
@@ -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. - 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. - 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. - 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 type: User Story
title: "US-003: Configure each world's spawn area" title: "US-003: Configure each world's spawn area"
description: Let administrators define an independent center and circular safe-spawn radius for every world. 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 # 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 ## Acceptance criteria
- [ ] Every world has an independent spawn center and maximum spawn distance. - [x] 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. - [x] 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. - [x] `/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. - [x] `/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. - [x] `/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. - [x] 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. - [x] 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. - [x] 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. - [x] Negative, non-numeric, or otherwise invalid distances are rejected with an explanatory message.
- [ ] Random destinations use the center's saved yaw and pitch. - [x] Random destinations use the center's saved yaw and pitch.
- [ ] Per-world centers and distances survive server and plugin restarts. - [x] Per-world centers and distances survive server and plugin restarts.
## Related ## 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.io.IOException;
import java.util.logging.Level; import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
/** Entry point for the Trigger Spawn Spigot plugin. */ /** Entry point for the Trigger Spawn Spigot plugin. */
@@ -17,7 +18,14 @@ public final class TriggerSpawnPlugin extends JavaPlugin {
YamlSpawnStateRepository repository = YamlSpawnStateRepository repository =
new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml")); new YamlSpawnStateRepository(getDataFolder().toPath().resolve("state.yml"));
stateManager = SpawnStateManager.load(repository); 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); getLogger().log(Level.SEVERE, "Trigger Spawn could not initialize safely", exception);
getServer().getPluginManager().disablePlugin(this); 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());
}
}