Fix ghost blocks and add safe teleport

- Add BlockUpdateUtil for sending block update packets to clients
  - sendBlockUpdate() - update single block for visible players
  - sendRegionUpdate() - batch update for cuboid regions
  - sendLocationsUpdate() - efficient update for specific positions
  - refreshChunks() - full chunk resync if needed
  - findSafeTeleportLocation() - prevents player suffocation

- Update BukkitBlockSetter to send block updates after placement
  - Prevents ghost blocks when setting many blocks quickly
  - Uses main thread for packet sending after async block placement

- Update MineResetHandler.teleportPlayers() to use safe teleport
  - Finds safe location near configured teleport destination
  - Prevents players from spawning inside blocks

Ghost blocks occur when server sets blocks without notifying clients,
causing invisible blocks or desync. Fixed by explicit packet updates.
This commit is contained in:
loki5512344 2026-05-31 22:56:38 +02:00
parent 23d527e8a1
commit 061246511b
3 changed files with 260 additions and 3 deletions

View file

@ -2,6 +2,7 @@ package dev.loki.lomines.block;
import dev.loki.lomines.LoMinesPlugin; import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.data.config.block.BlockKey; import dev.loki.lomines.data.config.block.BlockKey;
import dev.loki.lomines.util.block.BlockUpdateUtil;
import dev.loki.lomines.util.location.Cuboid; import dev.loki.lomines.util.location.Cuboid;
import dev.lolib.scheduler.Scheduler; import dev.lolib.scheduler.Scheduler;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@ -18,6 +19,9 @@ import java.util.function.IntConsumer;
* BlockSetter implementation for vanilla Minecraft blocks. * BlockSetter implementation for vanilla Minecraft blocks.
* Uses Bukkit API to set blocks with optimal performance settings. * Uses Bukkit API to set blocks with optimal performance settings.
* Updated for BlockKey type-safe configuration. * Updated for BlockKey type-safe configuration.
*
* <p>Prevents ghost blocks by sending block update packets to clients
* after bulk block placement.</p>
*/ */
public final class BukkitBlockSetter extends BlockSetter { public final class BukkitBlockSetter extends BlockSetter {
@ -34,7 +38,13 @@ public final class BukkitBlockSetter extends BlockSetter {
Scheduler.get(plugin).runAsync(() -> { Scheduler.get(plugin).runAsync(() -> {
int count = fillSync(region); int count = fillSync(region);
// Send block updates on main thread to prevent ghost blocks
Scheduler.get(plugin).run(() -> { Scheduler.get(plugin).run(() -> {
BlockUpdateUtil.sendRegionUpdate(
region.getWorld(),
region.getMinX(), region.getMinY(), region.getMinZ(),
region.getMaxX(), region.getMaxY(), region.getMaxZ()
);
callback.accept(count); callback.accept(count);
}); });
}); });
@ -48,7 +58,12 @@ public final class BukkitBlockSetter extends BlockSetter {
} }
Scheduler.get(plugin).runAsync(() -> { Scheduler.get(plugin).runAsync(() -> {
int count = fillAtLocationsSync(locations); int count = fillAtLocationsSync(locations);
Scheduler.get(plugin).run(() -> callback.accept(count));
// Send block updates on main thread to prevent ghost blocks
Scheduler.get(plugin).run(() -> {
BlockUpdateUtil.sendLocationsUpdate(locations);
callback.accept(count);
});
}); });
} }

View file

@ -3,6 +3,7 @@ package dev.loki.lomines.handler;
import dev.loki.lomines.LoMinesPlugin; import dev.loki.lomines.LoMinesPlugin;
import dev.loki.lomines.core.Mine; import dev.loki.lomines.core.Mine;
import dev.loki.lomines.data.config.block.FillMode; import dev.loki.lomines.data.config.block.FillMode;
import dev.loki.lomines.util.block.BlockUpdateUtil;
import dev.loki.lomines.util.location.Cuboid; import dev.loki.lomines.util.location.Cuboid;
import dev.loki.lomines.util.location.LocationParser; import dev.loki.lomines.util.location.LocationParser;
import dev.lolib.scheduler.Scheduler; import dev.lolib.scheduler.Scheduler;
@ -170,7 +171,8 @@ public final class MineResetHandler {
} }
/** /**
* Teleports players standing inside the mine to the configured location. * Teleports players standing inside the mine to a safe location near the configured destination.
* Prevents players from suffocating in blocks by finding a safe teleport spot.
*/ */
private void teleportPlayers() { private void teleportPlayers() {
var destOpt = mine.getConfig().teleport().getLocation(); var destOpt = mine.getConfig().teleport().getLocation();
@ -181,9 +183,13 @@ public final class MineResetHandler {
if (dest.getWorld() == null) { if (dest.getWorld() == null) {
return; return;
} }
// Find a safe teleport location to prevent suffocation
Location safeDest = BlockUpdateUtil.findSafeTeleportLocation(dest);
for (Player p : dest.getWorld().getPlayers()) { for (Player p : dest.getWorld().getPlayers()) {
if (mine.contains(p.getLocation())) { if (mine.contains(p.getLocation())) {
p.teleport(dest); p.teleport(safeDest);
} }
} }
} }

View file

@ -0,0 +1,236 @@
package dev.loki.lomines.util.block;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import java.util.Collection;
/**
* Utility for sending block updates to clients to prevent ghost blocks.
*
* <p>Ghost blocks occur when the server sets blocks without notifying clients,
* causing a desync where blocks appear invisible or behave strangely on the client.
* This utility forces block updates to be sent to all relevant players.</p>
*/
public final class BlockUpdateUtil {
private BlockUpdateUtil() {
}
/**
* Sends a block update to all players who can see the specified chunk.
* This prevents ghost blocks by forcing the server to send the block state to clients.
*
* @param block the block to update
*/
public static void sendBlockUpdate(Block block) {
if (block == null) {
return;
}
// Get chunk and players in it
Chunk chunk = block.getChunk();
World world = block.getWorld();
// Send block change to all players in the world who can see this chunk
for (Player player : world.getPlayers()) {
if (isChunkVisibleToPlayer(player, chunk)) {
player.sendBlockChange(block.getLocation(), block.getBlockData());
}
}
}
/**
* Sends block updates for all blocks in a cuboid region.
* Uses batching to minimize packet overhead.
*
* @param world the world containing the blocks
* @param minX minimum X coordinate
* @param minY minimum Y coordinate
* @param minZ minimum Z coordinate
* @param maxX maximum X coordinate
* @param maxY maximum Y coordinate
* @param maxZ maximum Z coordinate
*/
public static void sendRegionUpdate(World world, int minX, int minY, int minZ,
int maxX, int maxY, int maxZ) {
Collection<Player> players = world.getPlayers();
for (int x = minX; x <= maxX; x++) {
for (int y = minY; y <= maxY; y++) {
for (int z = minZ; z <= maxZ; z++) {
Block block = world.getBlockAt(x, y, z);
for (Player player : players) {
if (isLocationVisibleToPlayer(player, block.getLocation())) {
player.sendBlockChange(block.getLocation(), block.getBlockData());
}
}
}
}
}
}
/**
* Sends updates for a list of locations. More efficient than region update
* when only specific positions need updating.
*
* @param locations the locations to update
*/
public static void sendLocationsUpdate(java.util.List<Location> locations) {
if (locations == null || locations.isEmpty()) {
return;
}
for (Location loc : locations) {
if (loc.getWorld() == null) {
continue;
}
Block block = loc.getBlock();
for (Player player : loc.getWorld().getPlayers()) {
if (isLocationVisibleToPlayer(player, loc)) {
player.sendBlockChange(loc, block.getBlockData());
}
}
}
}
/**
* Refreshes chunks for all players in a region.
* This is a heavier operation but ensures complete chunk resync.
*
* @param world the world
* @param minX minimum block X
* @param minZ minimum block Z
* @param maxX maximum block X
* @param maxZ maximum block Z
*/
public static void refreshChunks(World world, int minX, int minZ, int maxX, int maxZ) {
int minChunkX = minX >> 4;
int minChunkZ = minZ >> 4;
int maxChunkX = maxX >> 4;
int maxChunkZ = maxZ >> 4;
for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) {
for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) {
Chunk chunk = world.getChunkAt(chunkX, chunkZ);
if (chunk.isLoaded()) {
for (Player player : world.getPlayers()) {
player.sendChunkUpdate(chunk);
}
}
}
}
}
/**
* Finds a safe teleport location near the given destination.
* Checks for suffocation hazards (blocks at head/body level).
*
* @param destination the desired destination
* @return a safe location (may be the same as destination if safe)
*/
public static Location findSafeTeleportLocation(Location destination) {
if (destination == null || destination.getWorld() == null) {
return destination;
}
World world = destination.getWorld();
int x = destination.getBlockX();
int y = destination.getBlockY();
int z = destination.getBlockZ();
float yaw = destination.getYaw();
float pitch = destination.getPitch();
// Check if original location is safe
if (isSafeLocation(world, x, y, z)) {
return destination;
}
// Search upward for a safe spot (priority: don't drop player down)
for (int offset = 1; offset <= 5; offset++) {
if (isSafeLocation(world, x, y + offset, z)) {
return new Location(world, x + 0.5, y + offset, z + 0.5, yaw, pitch);
}
}
// Search downward if no safe spot above
for (int offset = 1; offset <= 5 && y - offset >= world.getMinHeight(); offset++) {
if (isSafeLocation(world, x, y - offset, z)) {
return new Location(world, x + 0.5, y - offset, z + 0.5, yaw, pitch);
}
}
// Search nearby blocks
int[][] nearby = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {-1, -1}, {1, -1}, {-1, 1}};
for (int[] offset : nearby) {
for (int yOffset = 0; yOffset <= 3; yOffset++) {
int newY = y + yOffset;
if (newY < world.getMinHeight() || newY >= world.getMaxHeight()) {
continue;
}
if (isSafeLocation(world, x + offset[0], newY, z + offset[1])) {
return new Location(world, x + offset[0] + 0.5, newY, z + offset[1] + 0.5, yaw, pitch);
}
}
}
// If nothing else works, return original but at least center it
return new Location(world, x + 0.5, y, z + 0.5, yaw, pitch);
}
/**
* Checks if a location is safe for teleport (no suffocation).
*/
private static boolean isSafeLocation(World world, int x, int y, int z) {
// Check feet position (can be passable)
Block feetBlock = world.getBlockAt(x, y, z);
// Check head position (must be passable)
Block headBlock = world.getBlockAt(x, y + 1, z);
// Safe if feet is passable and head is passable
return isPassable(feetBlock) && isPassable(headBlock);
}
/**
* Checks if a block is passable (not solid).
*/
private static boolean isPassable(Block block) {
return !block.getType().isSolid() || block.getType().isAir();
}
/**
* Checks if a player can see a chunk (is within render distance).
*/
private static boolean isChunkVisibleToPlayer(Player player, Chunk chunk) {
if (!player.getWorld().equals(chunk.getWorld())) {
return false;
}
int renderDistance = player.getClientViewDistance();
int playerChunkX = player.getLocation().getBlockX() >> 4;
int playerChunkZ = player.getLocation().getBlockZ() >> 4;
int dx = Math.abs(playerChunkX - chunk.getX());
int dz = Math.abs(playerChunkZ - chunk.getZ());
// Check if chunk is within player's view distance
return dx <= renderDistance && dz <= renderDistance;
}
/**
* Checks if a location is visible to a player (within render distance).
*/
private static boolean isLocationVisibleToPlayer(Player player, Location loc) {
if (!player.getWorld().equals(loc.getWorld())) {
return false;
}
double renderDistance = player.getClientViewDistance() * 16; // blocks
return player.getLocation().distanceSquared(loc) <= renderDistance * renderDistance;
}
}