diff --git a/README.md b/README.md index b46522f..11eb881 100644 --- a/README.md +++ b/README.md @@ -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 +/spawnadmin info +``` + For a local versioned build: ```bash diff --git a/build.gradle.kts b/build.gradle.kts index 266e1d3..97df943 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } diff --git a/design/log.md b/design/log.md index 5c19e38..281b91c 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-003-configure-world-spawn-area.md b/design/user-stories/us-003-configure-world-spawn-area.md index 2bf5066..d488017 100644 --- a/design/user-stories/us-003-configure-world-spawn-area.md +++ b/design/user-stories/us-003-configure-world-spawn-area.md @@ -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 ` 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 ` 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 diff --git a/src/main/java/games/dmg/triggerspawn/CircularSpawnSampler.java b/src/main/java/games/dmg/triggerspawn/CircularSpawnSampler.java new file mode 100644 index 0000000..6e6c2c0 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/CircularSpawnSampler.java @@ -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()); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java b/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java new file mode 100644 index 0000000..8c0ff52 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SpawnAdminCommand.java @@ -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 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 "); + 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 + " "); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnAreaService.java b/src/main/java/games/dmg/triggerspawn/SpawnAreaService.java new file mode 100644 index 0000000..96162bc --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SpawnAreaService.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index e001397..803c724 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -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); } diff --git a/src/test/java/games/dmg/triggerspawn/CircularSpawnSamplerTest.java b/src/test/java/games/dmg/triggerspawn/CircularSpawnSamplerTest.java new file mode 100644 index 0000000..de1cdd1 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/CircularSpawnSamplerTest.java @@ -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())); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java b/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java new file mode 100644 index 0000000..6357a57 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/SpawnAdminCommandTest.java @@ -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")); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/SpawnAreaServiceTest.java b/src/test/java/games/dmg/triggerspawn/SpawnAreaServiceTest.java new file mode 100644 index 0000000..9ecf401 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/SpawnAreaServiceTest.java @@ -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()); + } +}