feat(chunkloader): add mob spawning simulation, spawner activation and despawn protection

This commit is contained in:
Marcos Paulo
2026-08-20 12:53:19 -03:00
parent 4261e8b660
commit 4b9507b36e
5 changed files with 171 additions and 0 deletions
@@ -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);
@@ -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);
}
@@ -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);
}
}
}
@@ -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<ChunkLoader> 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<ChunkLoader> 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;
@@ -104,4 +104,11 @@ class ChunkLoaderTest {
assertNotNull(l2);
assertEquals("uuid-ana", l2.ownerUuid());
}
@Test
void simulationSafelyNoopsWithNullPlugin() {
loaders.startSimulation();
loaders.tickMobSimulation();
loaders.stopSimulation();
}
}