diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index fc49c5b..9e6d8bc 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -131,6 +131,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml")); if (settings.moduleEnabled(Module.CHUNKLOADER)) { chunkLoaders.loadAllTickets(); + chunkLoaders.startSimulation(); ChunkAnchorItem.registerRecipe(this); } aiBudget = new Budget(settings.aiSpontaneousPerDay(), @@ -192,6 +193,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { poll.hide(); } if (chunkLoaders != null) { + chunkLoaders.stopSimulation(); chunkLoaders.unloadAllTickets(); chunkLoaders.close(); ChunkAnchorItem.unregisterRecipe(this); diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 6b18ee5..0ede027 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -570,8 +570,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { if (module == Module.CHUNKLOADER) { if (on) { plugin.chunkLoaders().loadAllTickets(); + plugin.chunkLoaders().startSimulation(); ChunkAnchorItem.registerRecipe(plugin); } else { + plugin.chunkLoaders().stopSimulation(); plugin.chunkLoaders().unloadAllTickets(); ChunkAnchorItem.unregisterRecipe(plugin); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java index a0ccc58..60f92f6 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaderListener.java @@ -5,7 +5,9 @@ import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Particle; import org.bukkit.Sound; +import org.bukkit.World; import org.bukkit.block.Block; +import org.bukkit.entity.Mob; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -16,6 +18,7 @@ import org.bukkit.event.block.BlockExplodeEvent; import org.bukkit.event.block.BlockPistonExtendEvent; import org.bukkit.event.block.BlockPistonRetractEvent; import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.inventory.ItemStack; @@ -211,4 +214,20 @@ final class ChunkLoaderListener implements Listener { } } } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onCreatureSpawn(CreatureSpawnEvent event) { + if (!plugin.settings().moduleEnabled(Module.CHUNKLOADER)) { + return; + } + Location loc = event.getLocation(); + World w = loc.getWorld(); + if (w == null) { + return; + } + ChunkLoader loader = plugin.chunkLoaders().byChunk(w.getName(), loc.getBlockX() >> 4, loc.getBlockZ() >> 4); + if (loader != null && event.getEntity() instanceof Mob mob) { + mob.setRemoveWhenFarAway(false); + } + } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java index 8a40ba3..37bd90e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java +++ b/src/main/java/dev/marcospaulo/canalhandia/ChunkLoaders.java @@ -1,12 +1,22 @@ package dev.marcospaulo.canalhandia; import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Material; import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.CreatureSpawner; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Mob; import org.bukkit.entity.Player; +import org.bukkit.event.entity.CreatureSpawnEvent; import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; import java.io.File; import java.util.ArrayList; @@ -15,6 +25,7 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; @@ -29,6 +40,7 @@ final class ChunkLoaders { private final File file; private final List loaders = new ArrayList<>(); private final AtomicLong nextId = new AtomicLong(1); + private BukkitTask simulationTask; private final ExecutorService io = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "canalhandia-chunkloaders-io"); @@ -220,6 +232,135 @@ final class ChunkLoaders { } } + void startSimulation() { + if (plugin == null || simulationTask != null) { + return; + } + simulationTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickMobSimulation, 20L, 20L); + } + + void stopSimulation() { + if (simulationTask != null) { + simulationTask.cancel(); + simulationTask = null; + } + } + + void tickMobSimulation() { + if (plugin == null) { + return; + } + List currentLoaders; + synchronized (loaders) { + currentLoaders = new ArrayList<>(loaders); + } + + for (ChunkLoader loader : currentLoaders) { + try { + World w = Bukkit.getWorld(loader.world()); + if (w == null || !w.isChunkLoaded(loader.chunkX(), loader.chunkZ())) { + continue; + } + Chunk chunk = w.getChunkAt(loader.chunkX(), loader.chunkZ()); + + // 1. Keep mob spawners (dungeon / blaze / skeleton cages) active + for (BlockState state : chunk.getTileEntities()) { + if (state instanceof CreatureSpawner spawner) { + if (spawner.getRequiredPlayerRange() < 1024) { + spawner.setRequiredPlayerRange(2048); + spawner.update(true, false); + } + } + } + + // 2. Simulate natural mob spawning (dark platforms / slime / nether farms) + long mobCount = 0; + for (Entity entity : chunk.getEntities()) { + if (entity instanceof Mob) { + mobCount++; + } + } + if (mobCount >= 20) { + continue; // Respect chunk mob cap + } + + simulateNaturalSpawning(w, loader, chunk); + } catch (Exception ignored) { + } + } + } + + private void simulateNaturalSpawning(World w, ChunkLoader loader, Chunk chunk) { + int baseX = loader.chunkX() << 4; + int baseZ = loader.chunkZ() << 4; + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + + for (int i = 0; i < 2; i++) { + int rx = baseX + rnd.nextInt(16); + int rz = baseZ + rnd.nextInt(16); + int topY = Math.max(w.getMinHeight() + 2, Math.min(w.getMaxHeight() - 2, loader.y() + rnd.nextInt(-24, 25))); + + Block ground = w.getBlockAt(rx, topY - 1, rz); + Block space = w.getBlockAt(rx, topY, rz); + Block spaceAbove = w.getBlockAt(rx, topY + 1, rz); + + if (!ground.getType().isSolid() || !space.getType().isAir() || !spaceAbove.getType().isAir()) { + continue; + } + if (ground.isLiquid() || ground.getType() == Material.LAVA || ground.getType() == Material.WATER) { + continue; + } + + int light = space.getLightLevel(); + EntityType toSpawn = pickEntityType(w, space, light); + if (toSpawn == null) { + continue; + } + + try { + Entity spawned = w.spawnEntity(space.getLocation().add(0.5, 0, 0.5), toSpawn, CreatureSpawnEvent.SpawnReason.NATURAL); + if (spawned instanceof Mob mob) { + mob.setRemoveWhenFarAway(false); + } + } catch (Exception ignored) { + } + } + } + + static EntityType pickEntityType(World w, Block space, int light) { + World.Environment env = w.getEnvironment(); + ThreadLocalRandom rnd = ThreadLocalRandom.current(); + + if (env == World.Environment.NETHER) { + if (light > 11) return null; + int roll = rnd.nextInt(100); + if (roll < 45) return EntityType.ZOMBIFIED_PIGLIN; + if (roll < 65) return EntityType.WITHER_SKELETON; + if (roll < 85) return EntityType.BLAZE; + return EntityType.MAGMA_CUBE; + } + + if (env == World.Environment.THE_END) { + return EntityType.ENDERMAN; + } + + boolean isSlimeChunk = space.getChunk().isSlimeChunk() && space.getY() < 40; + if (isSlimeChunk && light <= 7 && rnd.nextInt(3) == 0) { + return EntityType.SLIME; + } + + if (light > 0) { + return null; + } + + int roll = rnd.nextInt(100); + if (roll < 35) return EntityType.ZOMBIE; + if (roll < 65) return EntityType.SKELETON; + if (roll < 85) return EntityType.CREEPER; + if (roll < 95) return EntityType.SPIDER; + return EntityType.WITCH; + } + private void addTicket(ChunkLoader loader) { if (plugin == null) { return; diff --git a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java index e251b17..4ea4c7e 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/ChunkLoaderTest.java @@ -104,4 +104,11 @@ class ChunkLoaderTest { assertNotNull(l2); assertEquals("uuid-ana", l2.ownerUuid()); } + + @Test + void simulationSafelyNoopsWithNullPlugin() { + loaders.startSimulation(); + loaders.tickMobSimulation(); + loaders.stopSimulation(); + } }