diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index 4dd556e..c2741d0 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -96,6 +96,7 @@ import net.tfminecraft.rpcharacters.grave.GraveManager; import net.tfminecraft.rpcharacters.grave.GraveVisualManager; import net.tfminecraft.rpcharacters.grave.LastSolidTracker; +import net.tfminecraft.rpcharacters.joinsafety.JoinUnstuckListener; import net.tfminecraft.rpcharacters.playerlist.PlayerListCommand; import net.tfminecraft.rpcharacters.playerlist.QuickActionPack; import net.tfminecraft.rpcharacters.pvp.PvpCommand; @@ -347,6 +348,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(new GraveInteractListener(), this); getServer().getPluginManager().registerEvents(new GraveInsuranceListener(), this); getServer().getPluginManager().registerEvents(new EvilRpListener(), this); + getServer().getPluginManager().registerEvents(new JoinUnstuckListener(), this); } public void startManagers() { playerManager.start(); diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java index a78a911..1c02a91 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; @@ -36,6 +37,7 @@ public final class GraveManager { private static final GraveManager INSTANCE = new GraveManager(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final int SEARCH_RADIUS = 2; + private static final List SEARCH_OFFSETS = searchOffsets(SEARCH_RADIUS); private final Map byId = new ConcurrentHashMap<>(); private final Map byBlock = new ConcurrentHashMap<>(); @@ -409,23 +411,30 @@ private static boolean isWaterLike(Block block) { private Block searchNearby(Block origin) { World world = origin.getWorld(); - int ox = origin.getX(); - int oy = origin.getY(); - int oz = origin.getZ(); + for (int[] offset : SEARCH_OFFSETS) { + Block candidate = world.getBlockAt(origin.getX() + offset[0], origin.getY() + offset[1], + origin.getZ() + offset[2]); + if (canPlace(candidate)) { + return candidate; + } + } + return null; + } + + /** Nearest first, so the grave lands next to the death spot rather than in a far corner. */ + static List searchOffsets(int radius) { + List offsets = new ArrayList<>(); for (int dy = 0; dy <= 1; dy++) { - for (int dx = -SEARCH_RADIUS; dx <= SEARCH_RADIUS; dx++) { - for (int dz = -SEARCH_RADIUS; dz <= SEARCH_RADIUS; dz++) { - if (dx == 0 && dy == 0 && dz == 0) { - continue; - } - Block candidate = world.getBlockAt(ox + dx, oy + dy, oz + dz); - if (canPlace(candidate)) { - return candidate; + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + if (dx != 0 || dy != 0 || dz != 0) { + offsets.add(new int[] { dx, dy, dz }); } } } } - return null; + offsets.sort(Comparator.comparingInt(o -> o[0] * o[0] + o[1] * o[1] + o[2] * o[2])); + return offsets; } private boolean canPlace(Block block) { diff --git a/src/main/java/net/tfminecraft/rpcharacters/joinsafety/JoinUnstuckListener.java b/src/main/java/net/tfminecraft/rpcharacters/joinsafety/JoinUnstuckListener.java new file mode 100644 index 0000000..88ed586 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/joinsafety/JoinUnstuckListener.java @@ -0,0 +1,149 @@ +package net.tfminecraft.rpcharacters.joinsafety; + +import java.util.EnumSet; +import java.util.Set; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.util.BoundingBox; + +import net.tfminecraft.rpcharacters.RPCharacters; + +/** + * Moves players out of blocks when they log in. Someone may have built where + * they logged out, and they would suffocate while the resource pack loads. + */ +public final class JoinUnstuckListener implements Listener { + + private static final String MOVED_MESSAGE = + "\u00A7eYou logged in inside a block, so you were moved to a safe spot nearby."; + + private static final Set HAZARDS = EnumSet.of( + Material.LAVA, Material.FIRE, Material.SOUL_FIRE, Material.MAGMA_BLOCK, + Material.CAMPFIRE, Material.SOUL_CAMPFIRE, Material.CACTUS, Material.SWEET_BERRY_BUSH, + Material.POWDER_SNOW, Material.WITHER_ROSE, Material.POINTED_DRIPSTONE); + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + // A teleport inside the join event is lost when the client's first position syncs. + // Scheduled tasks run before players tick, so this still lands before suffocation damage. + Bukkit.getScheduler().runTask(RPCharacters.plugin, () -> unstick(player)); + } + + private static void unstick(Player player) { + if (!player.isOnline()) { + return; + } + GameMode mode = player.getGameMode(); + if (mode == GameMode.CREATIVE || mode == GameMode.SPECTATOR || player.isInsideVehicle()) { + return; + } + // Vanilla drops a player who still fits crawling into the swimming pose, so leave them be. + if (!isInWall(player) || !crawlSpaceBlocked(player)) { + return; + } + Location from = player.getLocation(); + Location target = findSafeSpot(from); + if (target == null) { + return; + } + if (!player.teleport(target)) { + return; + } + // PlayerManager froze no-character players in the wall on join; re-capture it here. + RPCharacters.getPlayerManager().releaseFreeze(player); + RPCharacters.getPlayerManager().reevaluateFreeze(player); + player.sendMessage(MOVED_MESSAGE); + RPCharacters.plugin.getLogger().info("Moved " + player.getName() + " out of blocks on join: " + + describe(from) + " -> " + describe(target)); + } + + /** Same test vanilla uses for suffocation damage: a thin box at eye level. */ + static boolean isInWall(Player player) { + Location eye = player.getEyeLocation(); + double half = player.getWidth() * 0.4; + BoundingBox box = new BoundingBox( + eye.getX() - half, eye.getY() - 1.0E-6, eye.getZ() - half, + eye.getX() + half, eye.getY() + 1.0E-6, eye.getZ() + half); + return collides(player.getWorld(), box, true); + } + + /** True when blocks fill the space a crawling (swimming pose) player would need. */ + static boolean crawlSpaceBlocked(Player player) { + Location feet = player.getLocation(); + double half = player.getWidth() / 2.0 - 1.0E-6; + BoundingBox box = new BoundingBox( + feet.getX() - half, feet.getY() + 1.0E-6, feet.getZ() - half, + feet.getX() + half, feet.getY() + 0.6, feet.getZ() + half); + return collides(player.getWorld(), box, false); + } + + private static boolean collides(World world, BoundingBox box, boolean suffocatingOnly) { + for (int x = floor(box.getMinX()); x <= floor(box.getMaxX()); x++) { + for (int y = floor(box.getMinY()); y <= floor(box.getMaxY()); y++) { + for (int z = floor(box.getMinZ()); z <= floor(box.getMaxZ()); z++) { + Block block = world.getBlockAt(x, y, z); + if (suffocatingOnly && !block.isSuffocating()) { + continue; + } + if (block.getCollisionShape().overlaps(box.clone().shift(-x, -y, -z))) { + return true; + } + } + } + } + return false; + } + + private static Location findSafeSpot(Location from) { + World world = from.getWorld(); + if (world == null) { + return null; + } + int[] spot = SafeSpotSearch.find(from.getBlockX(), from.getBlockY(), from.getBlockZ(), + (x, y, z) -> canStand(world, x, y, z)); + if (spot == null) { + Block top = world.getHighestBlockAt(from.getBlockX(), from.getBlockZ()); + if (!canStand(world, top.getX(), top.getY() + 1, top.getZ())) { + return null; + } + spot = new int[] { top.getX(), top.getY() + 1, top.getZ() }; + } + Location target = new Location(world, spot[0] + 0.5, spot[1], spot[2] + 0.5); + target.setYaw(from.getYaw()); + target.setPitch(from.getPitch()); + return target; + } + + private static boolean canStand(World world, int x, int y, int z) { + if (y - 1 < world.getMinHeight() || y + 1 >= world.getMaxHeight()) { + return false; + } + Block floor = world.getBlockAt(x, y - 1, z); + if (!floor.getType().isSolid() || HAZARDS.contains(floor.getType())) { + return false; + } + return isOpen(world.getBlockAt(x, y, z)) && isOpen(world.getBlockAt(x, y + 1, z)); + } + + private static boolean isOpen(Block block) { + return block.isPassable() && !block.isLiquid() && !HAZARDS.contains(block.getType()); + } + + private static int floor(double value) { + return (int) Math.floor(value); + } + + private static String describe(Location loc) { + return loc.getBlockX() + ", " + loc.getBlockY() + ", " + loc.getBlockZ(); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/joinsafety/SafeSpotSearch.java b/src/main/java/net/tfminecraft/rpcharacters/joinsafety/SafeSpotSearch.java new file mode 100644 index 0000000..4909cec --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/joinsafety/SafeSpotSearch.java @@ -0,0 +1,61 @@ +package net.tfminecraft.rpcharacters.joinsafety; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Finds the nearest block position a player can stand at, checking closer offsets first. + */ +public final class SafeSpotSearch { + + public static final int HORIZONTAL_RADIUS = 8; + public static final int VERTICAL_RADIUS = 8; + + private static final List OFFSETS = buildOffsets(HORIZONTAL_RADIUS, VERTICAL_RADIUS); + + @FunctionalInterface + public interface Probe { + /** True when feet at (x, y, z) are safe: open feet and head blocks on a solid floor. */ + boolean canStand(int x, int y, int z); + } + + private SafeSpotSearch() { + } + + /** + * @return {x, y, z} of the nearest standable position, or {@code null} if none is in range + */ + public static int[] find(int x, int y, int z, Probe probe) { + if (probe == null) { + return null; + } + for (int[] offset : OFFSETS) { + int cx = x + offset[0]; + int cy = y + offset[1]; + int cz = z + offset[2]; + if (probe.canStand(cx, cy, cz)) { + return new int[] { cx, cy, cz }; + } + } + return null; + } + + /** Offsets ordered by distance; ties prefer less vertical movement, then moving up. */ + static List buildOffsets(int horizontal, int vertical) { + List offsets = new ArrayList<>(); + for (int dx = -horizontal; dx <= horizontal; dx++) { + for (int dy = -vertical; dy <= vertical; dy++) { + for (int dz = -horizontal; dz <= horizontal; dz++) { + offsets.add(new int[] { dx, dy, dz }); + } + } + } + offsets.sort(Comparator.comparingInt(o -> o[0] * o[0] + o[1] * o[1] + o[2] * o[2]) + .thenComparingInt(o -> Math.abs(o[1])) + .thenComparingInt(o -> -o[1]) + .thenComparingInt(o -> o[0]) + .thenComparingInt(o -> o[2])); + return offsets; + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveSearchOffsetsTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveSearchOffsetsTest.java new file mode 100644 index 0000000..34b400f --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveSearchOffsetsTest.java @@ -0,0 +1,34 @@ +package net.tfminecraft.rpcharacters.grave; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class GraveSearchOffsetsTest { + + private static int distanceSq(int[] o) { + return o[0] * o[0] + o[1] * o[1] + o[2] * o[2]; + } + + @Test + void coversRadiusTwoOnSameLevelAndOneAboveWithoutOrigin() { + List offsets = GraveManager.searchOffsets(2); + assertEquals(5 * 5 * 2 - 1, offsets.size()); + for (int[] o : offsets) { + assertTrue(o[0] != 0 || o[1] != 0 || o[2] != 0); + } + } + + @Test + void checksNearestBlocksFirst() { + List offsets = GraveManager.searchOffsets(2); + assertEquals(1, distanceSq(offsets.get(0))); + assertEquals(0, offsets.get(0)[1], "same level is tried before the block above"); + for (int i = 1; i < offsets.size(); i++) { + assertTrue(distanceSq(offsets.get(i - 1)) <= distanceSq(offsets.get(i))); + } + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/joinsafety/SafeSpotSearchTest.java b/src/test/java/net/tfminecraft/rpcharacters/joinsafety/SafeSpotSearchTest.java new file mode 100644 index 0000000..4b1c8d3 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/joinsafety/SafeSpotSearchTest.java @@ -0,0 +1,62 @@ +package net.tfminecraft.rpcharacters.joinsafety; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class SafeSpotSearchTest { + + private static SafeSpotSearch.Probe spots(int[]... positions) { + Set open = new HashSet<>(); + for (int[] p : positions) { + open.add(p[0] + ":" + p[1] + ":" + p[2]); + } + return (x, y, z) -> open.contains(x + ":" + y + ":" + z); + } + + @Test + void keepsOriginWhenItIsStandable() { + int[] found = SafeSpotSearch.find(4079, 196, 1663, spots(new int[] { 4079, 196, 1663 })); + assertArrayEquals(new int[] { 4079, 196, 1663 }, found); + } + + @Test + void picksNearestSpot() { + int[] found = SafeSpotSearch.find(4079, 196, 1663, spots( + new int[] { 4077, 196, 1661 }, + new int[] { 4079, 196, 1662 }, + new int[] { 4083, 196, 1663 })); + assertArrayEquals(new int[] { 4079, 196, 1662 }, found); + } + + @Test + void prefersUpOverDownAtEqualDistance() { + int[] found = SafeSpotSearch.find(0, 64, 0, spots(new int[] { 0, 63, 0 }, new int[] { 0, 65, 0 })); + assertArrayEquals(new int[] { 0, 65, 0 }, found); + } + + @Test + void prefersSidewaysOverVerticalAtEqualDistance() { + int[] found = SafeSpotSearch.find(0, 64, 0, spots(new int[] { 0, 65, 0 }, new int[] { 1, 64, 0 })); + assertArrayEquals(new int[] { 1, 64, 0 }, found); + } + + @Test + void returnsNullWhenNothingInRange() { + int far = SafeSpotSearch.HORIZONTAL_RADIUS + 1; + assertNull(SafeSpotSearch.find(0, 64, 0, spots(new int[] { far, 64, 0 }))); + } + + @Test + void offsetsCoverTheWholeBoxOnce() { + List offsets = SafeSpotSearch.buildOffsets(2, 1); + assertEquals(5 * 3 * 5, offsets.size()); + assertArrayEquals(new int[] { 0, 0, 0 }, offsets.get(0)); + } +}