From 87dec73f8f2f6d6c60afab92761fcb5c212be1cc Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 13:33:57 -0400 Subject: [PATCH] feat(spawn): add safe cooldown teleport --- README.md | 2 + design/log.md | 7 + .../us-002-teleport-safely-to-spawn.md | 36 +-- .../games/dmg/triggerspawn/NearestSafeY.java | 25 +++ .../dmg/triggerspawn/SafeSpawnFinder.java | 84 +++++++ .../games/dmg/triggerspawn/SpawnCommand.java | 208 ++++++++++++++++++ .../dmg/triggerspawn/TriggerSpawnPlugin.java | 17 ++ .../dmg/triggerspawn/NearestSafeYTest.java | 15 ++ .../dmg/triggerspawn/SpawnCommandTest.java | 55 +++++ 9 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 src/main/java/games/dmg/triggerspawn/NearestSafeY.java create mode 100644 src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java create mode 100644 src/main/java/games/dmg/triggerspawn/SpawnCommand.java create mode 100644 src/test/java/games/dmg/triggerspawn/NearestSafeYTest.java create mode 100644 src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java diff --git a/README.md b/README.md index d3c92bf..4b8290a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ A Spigot 26.2 plugin providing progression-gated, safe teleportation to each wor Players unlock `/spawn` by killing a Warden, Ender Dragon, or Wither after the plugin is installed. Their first, second, and third unique kills give default cooldowns of 8 hours, 4 hours, and 1 hour respectively. Each new unique kill clears the current cooldown immediately; repeated kills of the same boss do not count. +`/spawn` uses real elapsed cooldown time, including time offline. An accepted request displays a three-second countdown. Walking to another block, jumping, falling, teleporting, changing worlds, or disconnecting cancels it; looking around does not. The plugin samples a safe destination in the current world's circular spawn area, and failures do not consume the cooldown. + The behavior under development is specified in the [OKF design bundle](design/index.md). ## Requirements diff --git a/design/log.md b/design/log.md index 22fbc45..10d5216 100644 --- a/design/log.md +++ b/design/log.md @@ -57,3 +57,10 @@ description: Chronological record of significant Trigger Spawn design decisions. - Added player-attributed Warden, Ender Dragon, and Wither kill observation without importing historical accomplishments. - Unique kills unlock and improve the configured cooldown tier in any order; duplicate kills do not count. - Each credited kill persists UUID-based progress, clears the current cooldown, and displays an on-screen reward plus colored checklist. + +## 2026-08-08 — Safe spawn teleportation + +- Added `/spawn` eligibility and real-time cooldown enforcement with explicit friendly remaining-time messages. +- Added a three-second on-screen warm-up cancelled by block movement, jumping, falling, teleportation, world changes, or disconnects. +- Safe destinations are sampled within the current world's circle using bounded attempts and a closest-elevation search that rejects environmental hazards. +- Only completed teleports consume cooldown, and incomplete progression receives a post-teleport reminder. diff --git a/design/user-stories/us-002-teleport-safely-to-spawn.md b/design/user-stories/us-002-teleport-safely-to-spawn.md index 37d45e5..f56c047 100644 --- a/design/user-stories/us-002-teleport-safely-to-spawn.md +++ b/design/user-stories/us-002-teleport-safely-to-spawn.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Teleport safely to spawn" description: Let eligible players teleport to a safe randomized location in their current world's spawn area after a stationary warm-up. -status: backlog +status: done --- # US-002: Teleport safely to spawn @@ -11,23 +11,23 @@ As an **eligible player**, I want `/spawn` to take me safely to my current world ## Acceptance criteria -- [ ] `/spawn` is available after at least one qualifying unique enemy kill or an administrative grant. -- [ ] A player without access receives a message instructing them to defeat a Warden, Ender Dragon, or Wither and sees the colored progress checklist. -- [ ] A banned player cannot begin a spawn teleport. -- [ ] When an active cooldown prevents `/spawn`, the player is explicitly told that the command is unavailable and sees the remaining wait in a friendly duration format. -- [ ] Cooldowns use real elapsed time and therefore continue while the player is offline. -- [ ] An accepted teleport request starts a three-second on-screen countdown. -- [ ] The player may look around during the countdown without cancelling it. -- [ ] A change to the player's block X, Y, or Z coordinate cancels the countdown, including walking to another block, jumping, or falling. -- [ ] Teleportation or a world change during the countdown cancels it. -- [ ] The destination is sampled randomly within the circular spawn area configured for the player's current world. -- [ ] The destination provides non-hazardous solid ground, two blocks of clear headroom, and no immediate lava, fire, water, void, or other configured environmental hazard. -- [ ] Unsafe underwater, leaf-top, inaccessible Nether-roof, and void-exposed destinations are rejected. -- [ ] The safe search prefers a valid vertical position closest to the configured spawn center's elevation rather than automatically preferring the world's highest surface. -- [ ] The safe-location search uses a bounded number of attempts and reports failure clearly when no destination is found. -- [ ] Only a completed teleport starts the applicable cooldown. -- [ ] After a completed teleport, a player with unfinished boss progression is reminded that defeating each remaining unique boss permanently reduces the cooldown. -- [ ] A cancelled countdown or failed safe-location search does not consume the cooldown. +- [x] `/spawn` is available after at least one qualifying unique enemy kill or an administrative grant. +- [x] A player without access receives a message instructing them to defeat a Warden, Ender Dragon, or Wither and sees the colored progress checklist. +- [x] A banned player cannot begin a spawn teleport. +- [x] When an active cooldown prevents `/spawn`, the player is explicitly told that the command is unavailable and sees the remaining wait in a friendly duration format. +- [x] Cooldowns use real elapsed time and therefore continue while the player is offline. +- [x] An accepted teleport request starts a three-second on-screen countdown. +- [x] The player may look around during the countdown without cancelling it. +- [x] A change to the player's block X, Y, or Z coordinate cancels the countdown, including walking to another block, jumping, or falling. +- [x] Teleportation or a world change during the countdown cancels it. +- [x] The destination is sampled randomly within the circular spawn area configured for the player's current world. +- [x] The destination provides non-hazardous solid ground, two blocks of clear headroom, and no immediate lava, fire, water, void, or other configured environmental hazard. +- [x] Unsafe underwater, leaf-top, inaccessible Nether-roof, and void-exposed destinations are rejected. +- [x] The safe search prefers a valid vertical position closest to the configured spawn center's elevation rather than automatically preferring the world's highest surface. +- [x] The safe-location search uses a bounded number of attempts and reports failure clearly when no destination is found. +- [x] Only a completed teleport starts the applicable cooldown. +- [x] After a completed teleport, a player with unfinished boss progression is reminded that defeating each remaining unique boss permanently reduces the cooldown. +- [x] A cancelled countdown or failed safe-location search does not consume the cooldown. ## Related diff --git a/src/main/java/games/dmg/triggerspawn/NearestSafeY.java b/src/main/java/games/dmg/triggerspawn/NearestSafeY.java new file mode 100644 index 0000000..85ca923 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/NearestSafeY.java @@ -0,0 +1,25 @@ +package games.dmg.triggerspawn; + +import java.util.OptionalInt; +import java.util.function.IntPredicate; + +final class NearestSafeY { + private NearestSafeY() { + } + + static OptionalInt find(int centerY, int minimumY, int maximumY, IntPredicate isSafe) { + int clamped = Math.max(minimumY, Math.min(maximumY, centerY)); + int maxOffset = Math.max(clamped - minimumY, maximumY - clamped); + for (int offset = 0; offset <= maxOffset; offset++) { + int below = clamped - offset; + if (below >= minimumY && isSafe.test(below)) { + return OptionalInt.of(below); + } + int above = clamped + offset; + if (offset > 0 && above <= maximumY && isSafe.test(above)) { + return OptionalInt.of(above); + } + } + return OptionalInt.empty(); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java b/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java new file mode 100644 index 0000000..5a3c229 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java @@ -0,0 +1,84 @@ +package games.dmg.triggerspawn; + +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; +import java.util.random.RandomGenerator; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.World; +import org.bukkit.block.Block; + +final class SafeSpawnFinder { + private static final int MAX_ATTEMPTS = 32; + private static final Set UNSAFE_GROUND = EnumSet.of( + Material.LAVA, + Material.FIRE, + Material.SOUL_FIRE, + Material.MAGMA_BLOCK, + Material.CAMPFIRE, + Material.SOUL_CAMPFIRE, + Material.CACTUS, + Material.SWEET_BERRY_BUSH, + Material.POINTED_DRIPSTONE); + private static final Set UNSAFE_SPACE = EnumSet.of( + Material.LAVA, + Material.WATER, + Material.FIRE, + Material.SOUL_FIRE, + Material.POWDER_SNOW); + + Optional find( + World world, + SpawnLocation center, + int maxDistance, + RandomGenerator random) { + int attempts = maxDistance == 0 ? 1 : MAX_ATTEMPTS; + for (int attempt = 0; attempt < attempts; attempt++) { + SpawnLocation sampled = CircularSpawnSampler.sample(center, maxDistance, random); + int x = (int) Math.floor(sampled.x()); + int z = (int) Math.floor(sampled.z()); + Location borderCheck = new Location(world, x + 0.5, center.y(), z + 0.5); + if (!world.getWorldBorder().isInside(borderCheck)) { + continue; + } + int minimumFeetY = world.getMinHeight() + 1; + int maximumFeetY = world.getMaxHeight() - 2; + if (world.getEnvironment() == World.Environment.NETHER) { + maximumFeetY = Math.min(maximumFeetY, world.getLogicalHeight() - 1); + } + Optional y = NearestSafeY.find( + (int) Math.floor(center.y()), + minimumFeetY, + maximumFeetY, + candidate -> isSafe(world, x, candidate, z)) + .stream() + .boxed() + .findFirst(); + if (y.isPresent()) { + return Optional.of(new Location( + world, x + 0.5, y.orElseThrow(), z + 0.5, center.yaw(), center.pitch())); + } + } + return Optional.empty(); + } + + private static boolean isSafe(World world, int x, int feetY, int z) { + Block ground = world.getBlockAt(x, feetY - 1, z); + Block feet = world.getBlockAt(x, feetY, z); + Block head = world.getBlockAt(x, feetY + 1, z); + Material groundType = ground.getType(); + return groundType.isSolid() + && !Tag.LEAVES.isTagged(groundType) + && !UNSAFE_GROUND.contains(groundType) + && isClear(feet) + && isClear(head); + } + + private static boolean isClear(Block block) { + return block.isPassable() + && !block.isLiquid() + && !UNSAFE_SPACE.contains(block.getType()); + } +} diff --git a/src/main/java/games/dmg/triggerspawn/SpawnCommand.java b/src/main/java/games/dmg/triggerspawn/SpawnCommand.java new file mode 100644 index 0000000..6906951 --- /dev/null +++ b/src/main/java/games/dmg/triggerspawn/SpawnCommand.java @@ -0,0 +1,208 @@ +package games.dmg.triggerspawn; + +import java.io.IOException; +import java.time.Clock; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.random.RandomGenerator; +import java.util.logging.Level; +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.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +final class SpawnCommand implements CommandExecutor, Listener { + private final Plugin plugin; + private final SpawnStateManager stateManager; + private final AccessService access; + private final SpawnAreaService spawnAreas; + private final SafeSpawnFinder safeSpawnFinder; + private final Clock clock; + private final Map pending = new HashMap<>(); + + SpawnCommand( + Plugin plugin, + SpawnStateManager stateManager, + AccessService access, + SpawnAreaService spawnAreas, + SafeSpawnFinder safeSpawnFinder, + Clock clock) { + this.plugin = plugin; + this.stateManager = stateManager; + this.access = access; + this.spawnAreas = spawnAreas; + this.safeSpawnFinder = safeSpawnFinder; + this.clock = clock; + } + + @Override + public boolean onCommand( + CommandSender sender, Command command, String label, String[] arguments) { + if (!(sender instanceof Player player)) { + sender.sendMessage(ChatColor.RED + "/spawn can only be used by a player."); + return true; + } + if (arguments.length != 0) { + player.sendMessage(ChatColor.RED + "Usage: /spawn"); + return true; + } + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + if (state.banned()) { + player.sendMessage(ChatColor.RED + "You are banned from using or unlocking /spawn."); + return true; + } + if (!access.hasAccess(state)) { + player.sendMessage(ChatColor.RED + + "Defeat a Warden, Ender Dragon, or Wither to unlock /spawn."); + Messages.progressionChecklist(state.defeatedBosses()).forEach(player::sendMessage); + return true; + } + java.time.Duration remaining = access.remainingCooldown(state, Instant.now(clock)); + if (!remaining.isZero()) { + player.sendMessage(Messages.cooldownBlocked(remaining)); + return true; + } + World world = player.getWorld(); + Location nativeLocation = world.getSpawnLocation(); + SpawnLocation nativeSpawn = toSpawnLocation(nativeLocation); + WorldSpawnState worldSettings = spawnAreas.settings(world.getUID(), world.getName()); + SpawnLocation center = worldSettings.customCenter().orElse(nativeSpawn); + Optional destination = safeSpawnFinder.find( + world, center, worldSettings.maxDistance(), RandomGenerator.getDefault()); + if (destination.isEmpty()) { + player.sendMessage(ChatColor.RED + + "No safe spawn destination could be found. Your cooldown was not used."); + return true; + } + beginCountdown(player, destination.orElseThrow()); + return true; + } + + @EventHandler + public void onMove(PlayerMoveEvent event) { + PendingTeleport teleport = pending.get(event.getPlayer().getUniqueId()); + Location destination = event.getTo(); + if (teleport == null || destination == null) { + return; + } + if (!teleport.sameBlock(destination)) { + cancel(event.getPlayer(), ChatColor.RED + "Spawn teleport cancelled: you moved."); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + cancel(event.getPlayer(), null); + } + + void cancelAll() { + for (PendingTeleport teleport : pending.values()) { + teleport.task.cancel(); + } + pending.clear(); + } + + private void beginCountdown(Player player, Location destination) { + cancel(player, null); + Location start = player.getLocation(); + PendingTeleport teleport = new PendingTeleport(start); + pending.put(player.getUniqueId(), teleport); + teleport.task = plugin.getServer().getScheduler().runTaskTimer(plugin, new Runnable() { + private int seconds = 3; + + @Override + public void run() { + if (seconds > 0) { + player.sendTitle( + ChatColor.GOLD + "Teleporting to spawn", + ChatColor.YELLOW + Integer.toString(seconds), + 0, + 25, + 0); + seconds--; + return; + } + pending.remove(player.getUniqueId()); + teleport.task.cancel(); + if (player.teleport(destination)) { + recordSuccessfulUse(player); + player.sendTitle(ChatColor.GREEN + "Welcome to spawn", "", 5, 30, 10); + PlayerState completed = stateManager.player(player.getUniqueId(), player.getName()); + if (completed.defeatedBosses().size() < BossType.values().length) { + player.sendMessage(ChatColor.GOLD + + "Defeat each remaining boss to permanently reduce your /spawn cooldown."); + } + } else { + player.sendMessage(ChatColor.RED + + "The spawn teleport failed. Your cooldown was not used."); + } + } + }, 0L, 20L); + } + + private void recordSuccessfulUse(Player player) { + PlayerState current = stateManager.player(player.getUniqueId(), player.getName()); + try { + stateManager.putPlayer(new PlayerState( + current.playerId(), + player.getName(), + current.defeatedBosses(), + current.grantedCooldown(), + current.banned(), + Optional.of(Instant.now(clock)))); + } catch (IOException exception) { + plugin.getLogger().log(Level.SEVERE, "Could not persist /spawn cooldown", exception); + player.sendMessage(ChatColor.RED + "Your spawn cooldown could not be saved; contact an administrator."); + } + } + + private void cancel(Player player, String message) { + PendingTeleport removed = pending.remove(player.getUniqueId()); + if (removed != null && removed.task != null) { + removed.task.cancel(); + } + if (message != null) { + player.sendTitle(ChatColor.RED + "Teleport cancelled", "", 0, 30, 10); + player.sendMessage(message); + } + } + + private static SpawnLocation toSpawnLocation(Location location) { + return new SpawnLocation( + location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch()); + } + + private static final class PendingTeleport { + private final UUID worldId; + private final int blockX; + private final int blockY; + private final int blockZ; + private BukkitTask task; + + private PendingTeleport(Location start) { + this.worldId = start.getWorld().getUID(); + this.blockX = start.getBlockX(); + this.blockY = start.getBlockY(); + this.blockZ = start.getBlockZ(); + } + + private boolean sameBlock(Location location) { + return location.getWorld().getUID().equals(worldId) + && location.getBlockX() == blockX + && location.getBlockY() == blockY + && location.getBlockZ() == blockZ; + } + } +} diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index e025699..e9416d3 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -10,6 +10,7 @@ import org.bukkit.plugin.java.JavaPlugin; public final class TriggerSpawnPlugin extends JavaPlugin { private PluginSettings settings; private SpawnStateManager stateManager; + private SpawnCommand spawnCommand; @Override public void onEnable() { @@ -32,6 +33,19 @@ public final class TriggerSpawnPlugin extends JavaPlugin { } adminCommand.setExecutor(executor); adminCommand.setTabCompleter(executor); + spawnCommand = new SpawnCommand( + this, + stateManager, + access, + new SpawnAreaService(stateManager), + new SafeSpawnFinder(), + Clock.systemUTC()); + PluginCommand playerCommand = getCommand("spawn"); + if (playerCommand == null) { + throw new IllegalStateException("spawn is missing from plugin.yml"); + } + playerCommand.setExecutor(spawnCommand); + getServer().getPluginManager().registerEvents(spawnCommand, this); getServer().getPluginManager().registerEvents( new BossKillListener(this, new ProgressionService(stateManager, access)), this); } catch (RuntimeException | IOException exception) { @@ -42,6 +56,9 @@ public final class TriggerSpawnPlugin extends JavaPlugin { @Override public void onDisable() { + if (spawnCommand != null) { + spawnCommand.cancelAll(); + } if (stateManager == null) { return; } diff --git a/src/test/java/games/dmg/triggerspawn/NearestSafeYTest.java b/src/test/java/games/dmg/triggerspawn/NearestSafeYTest.java new file mode 100644 index 0000000..9728c13 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/NearestSafeYTest.java @@ -0,0 +1,15 @@ +package games.dmg.triggerspawn; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +final class NearestSafeYTest { + @Test + void choosesSafeElevationClosestToConfiguredCenter() { + assertEquals(68, NearestSafeY.find(70, 1, 90, y -> y == 40 || y == 68).orElseThrow()); + assertEquals(72, NearestSafeY.find(70, 1, 90, y -> y == 72 || y == 80).orElseThrow()); + assertTrue(NearestSafeY.find(70, 1, 90, y -> false).isEmpty()); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java b/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java new file mode 100644 index 0000000..89a7ba8 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java @@ -0,0 +1,55 @@ +package games.dmg.triggerspawn; + +import static org.mockito.ArgumentMatchers.contains; +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.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class SpawnCommandTest { + @TempDir + Path temporaryDirectory; + + @Test + void activeCooldownClearlyReportsRemainingWait() throws Exception { + UUID playerId = UUID.randomUUID(); + SpawnStateManager manager = SpawnStateManager.load(new YamlSpawnStateRepository( + temporaryDirectory.resolve("state.yml"))); + manager.putPlayer(new PlayerState( + playerId, + "Alex", + Set.of(BossType.WARDEN), + Optional.empty(), + false, + Optional.of(Instant.parse("2026-08-08T10:00:00Z")))); + PluginSettings settings = PluginSettings.from(new org.bukkit.configuration.MemoryConfiguration()); + AccessService access = new AccessService(manager, settings); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Alex"); + SpawnCommand spawn = new SpawnCommand( + mock(Plugin.class), + manager, + access, + new SpawnAreaService(manager), + new SafeSpawnFinder(), + Clock.fixed(Instant.parse("2026-08-08T11:00:00Z"), ZoneOffset.UTC)); + + spawn.onCommand(player, mock(Command.class), "spawn", new String[0]); + + verify(player).sendMessage(contains("cannot use /spawn yet")); + verify(player).sendMessage(contains("7h")); + } +}