From 440c083e2369db7dec28aa4de43fd92f408e968e Mon Sep 17 00:00:00 2001 From: PizzaConBacon Date: Tue, 30 Jun 2026 01:28:08 +0100 Subject: [PATCH 1/3] Added perlocal feature --- .../mcjty/incontrol/rules/RulesManager.java | 2 + .../incontrol/rules/support/CountInfo.java | 133 ++++++++-- .../rules/support/LocalDistanceRegistry.java | 34 +++ .../incontrol/rules/support/RuleCache.java | 228 +++++++++++++++++- .../tools/distance/PlayerDistanceMap.java | 176 ++++++++++++++ .../tools/distance/PooledHashSets.java | 223 +++++++++++++++++ 6 files changed, 764 insertions(+), 32 deletions(-) create mode 100644 src/main/java/mcjty/incontrol/rules/support/LocalDistanceRegistry.java create mode 100644 src/main/java/mcjty/incontrol/tools/distance/PlayerDistanceMap.java create mode 100644 src/main/java/mcjty/incontrol/tools/distance/PooledHashSets.java diff --git a/src/main/java/mcjty/incontrol/rules/RulesManager.java b/src/main/java/mcjty/incontrol/rules/RulesManager.java index 68b18ff..0a9f4c2 100644 --- a/src/main/java/mcjty/incontrol/rules/RulesManager.java +++ b/src/main/java/mcjty/incontrol/rules/RulesManager.java @@ -4,6 +4,7 @@ import mcjty.incontrol.ErrorHandler; import mcjty.incontrol.InControl; import mcjty.incontrol.data.DataStorage; +import mcjty.incontrol.rules.support.LocalDistanceRegistry; import mcjty.incontrol.rules.support.SpawnWhen; import mcjty.incontrol.tools.varia.JSonTools; import net.minecraft.world.level.Level; @@ -55,6 +56,7 @@ public static void reloadRules() { placeRules.clear(); rightclickRules.clear(); leftclickRules.clear(); + LocalDistanceRegistry.reset(); onPhaseChange(); readAllRules(); } diff --git a/src/main/java/mcjty/incontrol/rules/support/CountInfo.java b/src/main/java/mcjty/incontrol/rules/support/CountInfo.java index 7687084..418c679 100644 --- a/src/main/java/mcjty/incontrol/rules/support/CountInfo.java +++ b/src/main/java/mcjty/incontrol/rules/support/CountInfo.java @@ -1,9 +1,20 @@ package mcjty.incontrol.rules.support; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; + import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; + import mcjty.incontrol.ErrorHandler; import mcjty.incontrol.InControl; import mcjty.incontrol.compat.CustomNPCSupport; @@ -13,14 +24,6 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.level.LevelAccessor; -import org.apache.commons.lang3.StringUtils; - -import javax.annotation.Nullable; -import java.util.ArrayList; -import java.util.List; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Predicate; class CountInfo { public int amount; @@ -28,6 +31,11 @@ class CountInfo { public List entityTypes = new ArrayList<>(); public boolean scaledPerPlayer = false; public boolean scaledPerChunk = false; + public boolean scaledPerLocal = false; + public int minLocalDist = 0; // Value in blocks from where the perlocal rule will start counting mobs. + public int maxLocalDist = 120; // Value in blocks to where the perlocal rule will count mobs. Defaults to 120 since its the default maxdist value for spawner rules. + public int minLocalChunks = 0; // Converted value in chunks since the algorithm works with chunks and not blocks. + public int maxLocalChunks = 8; // Math.floor(120 + 8 / 16). public boolean passive = false; public boolean hostile = false; public boolean all = false; @@ -36,39 +44,83 @@ class CountInfo { public CountInfo() { } + // Converts the block distances (minLocalDist/maxLocalDist) into chunk radius distances and registers them in + // LocalDistanceRegistry so that RuleCache knows which PlayerDistanceMap instances to maintain. + private void computeLocalChunks() { + if (this.scaledPerLocal) { + this.minLocalChunks = (int) Math.floor((this.minLocalDist + 8.0) / 16.0) - 1; + this.maxLocalChunks = (int) Math.floor((this.maxLocalDist + 8.0) / 16.0); + LocalDistanceRegistry.register(this.minLocalChunks); + LocalDistanceRegistry.register(this.maxLocalChunks); + } + } + public BiFunction getCounter() { BiFunction counter; if (mod != null) { if (hostile) { - counter = (world, entity) -> InControl.setup.cache.getCountPerModHostile(world, mod); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountPerModHostile(world, entity, mod, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountPerModHostile(world, mod); } else if (passive) { - counter = (world, entity) -> InControl.setup.cache.getCountPerModPassive(world, mod); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountPerModPassive(world, entity, mod, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountPerModPassive(world, mod); } else if (all) { - counter = (world, entity) -> InControl.setup.cache.getCountPerModAll(world, mod); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountPerModAll(world, entity, mod, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountPerModAll(world, mod); } else { - counter = (world, entity) -> InControl.setup.cache.getCountPerMod(world, mod); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountPerMod(world, entity, mod, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountPerMod(world, mod); } } else if (hostile) { - counter = (world, entity) -> InControl.setup.cache.getCountHostile(world); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountHostile(world, entity, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountHostile(world); } else if (passive) { - counter = (world, entity) -> InControl.setup.cache.getCountPassive(world); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountPassive(world, entity, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountPassive(world); } else if (all) { - counter = (world, entity) -> InControl.setup.cache.getCountAll(world); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCountAll(world, entity, minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCountAll(world); } else { List infoEntityType = entityTypes; if (infoEntityType.isEmpty()) { if (ModSetup.customnpcs) { - counter = (world, entity) -> CustomNPCSupport.isNPC(entity) ? InControl.setup.cache.getNpcCount(world, entity) : InControl.setup.cache.getCount(world, entity.getType()); + counter = scaledPerLocal + ? (world, entity) -> CustomNPCSupport.isNPC(entity) + ? InControl.setup.cache.getLocalNpcCount(world, entity, minLocalChunks, maxLocalChunks) + : InControl.setup.cache.getLocalCount(world, entity, entity.getType(), minLocalChunks, maxLocalChunks) + : (world, entity) -> CustomNPCSupport.isNPC(entity) + ? InControl.setup.cache.getNpcCount(world, entity) + : InControl.setup.cache.getCount(world, entity.getType()); } else { - counter = (world, entity) -> InControl.setup.cache.getCount(world, entity.getType()); + counter = scaledPerLocal + ? (world, entity) -> InControl.setup.cache.getLocalCount(world, entity, entity.getType(), minLocalChunks, maxLocalChunks) + : (world, entity) -> InControl.setup.cache.getCount(world, entity.getType()); } } else if (infoEntityType.size() == 1) { - counter = (world, entity) -> { + counter = scaledPerLocal + ? (world, entity) -> { + EntityType entityType = infoEntityType.get(0); + return InControl.setup.cache.getLocalCount(world, entity, entityType, minLocalChunks, maxLocalChunks); + } : (world, entity) -> { EntityType entityType = infoEntityType.get(0); return InControl.setup.cache.getCount(world, entityType); }; } else { - counter = (world, entity) -> { + counter = scaledPerLocal + ? (world, entity) -> { + int amount = 0; + for (EntityType cls : infoEntityType) { + amount += InControl.setup.cache.getLocalCount(world, entity, cls, minLocalChunks, maxLocalChunks); + } + return amount; + } : (world, entity) -> { int amount = 0; for (EntityType cls : infoEntityType) { amount += InControl.setup.cache.getCount(world, cls); @@ -155,6 +207,15 @@ static CountInfo parseCountInfo(String json) { if (obj.has("perchunk")) { info.setScaledPerChunk(obj.get("perchunk").getAsBoolean()); } + if (obj.has("perlocal")) { + info.setScaledPerLocal(obj.get("perlocal").getAsBoolean()); + } + if (obj.has("minlocaldist")) { + info.setMinLocalDist(obj.get("minlocaldist").getAsInt()); + } + if (obj.has("maxlocaldist")) { + info.setMaxLocalDist(obj.get("maxlocaldist").getAsInt()); + } if (obj.has("passive")) { info.setPassive(obj.get("passive").getAsBoolean()); } @@ -169,6 +230,7 @@ static CountInfo parseCountInfo(String json) { ErrorHandler.error(error); return null; } + info.computeLocalChunks(); return info; } else { ErrorHandler.error("Count description '" + json + "' is not valid!"); @@ -212,6 +274,21 @@ public CountInfo setScaledPerChunk(boolean scaledPerChunk) { return this; } + public CountInfo setScaledPerLocal(boolean scaledPerLocal) { + this.scaledPerLocal = scaledPerLocal; + return this; + } + + public CountInfo setMinLocalDist(int minLocalDist) { + this.minLocalDist = minLocalDist; + return this; + } + + public CountInfo setMaxLocalDist(int maxLocalDist) { + this.maxLocalDist = maxLocalDist; + return this; + } + public CountInfo setAll(boolean all) { this.all = all; return this; @@ -236,6 +313,24 @@ public String validate() { if (scaledPerPlayer && scaledPerChunk) { return "You cannot combine 'perchunk' and 'perplayer'!"; } + if (scaledPerPlayer && scaledPerLocal) { + return "You cannot combine 'perlocal' and 'perplayer'!"; + } + if (scaledPerLocal && scaledPerChunk) { + return "You cannot combine 'perchunk' and 'perlocal'!"; + } + if (minLocalDist < 0) { + return "'minlocaldist' cannot be negative!"; + } + if (maxLocalDist <= 0) { + return "'maxlocaldist' must be positive!"; + } + if (minLocalDist >= maxLocalDist) { + return "'minlocaldist' must be smaller than 'maxlocaldist'!"; + } + if ((minLocalDist != 0 || maxLocalDist != 120) && !scaledPerLocal) { + return "'minlocaldist'/'maxlocaldist' only make sense together with 'perlocal: true'!"; + } if (mod != null && !entityTypes.isEmpty()) { return "You cannot combine 'mod' with 'mob'!"; } diff --git a/src/main/java/mcjty/incontrol/rules/support/LocalDistanceRegistry.java b/src/main/java/mcjty/incontrol/rules/support/LocalDistanceRegistry.java new file mode 100644 index 0000000..7f0dbe9 --- /dev/null +++ b/src/main/java/mcjty/incontrol/rules/support/LocalDistanceRegistry.java @@ -0,0 +1,34 @@ +package mcjty.incontrol.rules.support; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Tracks every distinct chunk radius that any loaded "perlocal" mincount/maxcount rule needs. + * RuleCache uses this to know which PlayerDistanceMap instances to keep alive. + * Gets populated while rules are parsed and is reset before a rule reload re-parses everything. + */ +public final class LocalDistanceRegistry { + // Distinct chunk radii to track + private static final Set RADII = new HashSet<>(); + + private LocalDistanceRegistry() {} + + // When a new chunk radius is needed it gets registered. We skip radii of distance less than 0 as they don't really make too much sense. + public static void register(int chunkRadius) { + if (chunkRadius >= 0) { + RADII.add(chunkRadius); + } + } + + // Straightforward + public static Set getRadii() { + return Collections.unmodifiableSet(RADII); + } + + // We reset radii when reloading rules + public static void reset() { + RADII.clear(); + } +} \ No newline at end of file diff --git a/src/main/java/mcjty/incontrol/rules/support/RuleCache.java b/src/main/java/mcjty/incontrol/rules/support/RuleCache.java index c865d8d..f5bd99a 100644 --- a/src/main/java/mcjty/incontrol/rules/support/RuleCache.java +++ b/src/main/java/mcjty/incontrol/rules/support/RuleCache.java @@ -1,13 +1,21 @@ package mcjty.incontrol.rules.support; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.ToIntFunction; + import mcjty.incontrol.compat.CustomNPCSupport; import mcjty.incontrol.mob.CNPCMob; import mcjty.incontrol.setup.Config; import mcjty.incontrol.setup.ModSetup; +import mcjty.incontrol.tools.distance.PlayerDistanceMap; import mcjty.incontrol.tools.varia.Tools; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.ResourceKey; import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.Mob; @@ -17,9 +25,6 @@ import net.minecraft.world.level.Level; import net.minecraft.world.level.LevelAccessor; -import java.util.HashMap; -import java.util.Map; - public class RuleCache { private final Map, CachePerWorld> caches = new HashMap<>(); @@ -78,7 +83,6 @@ public int getCountNeutral(LevelAccessor world) { return cache.getCountNeutral(); } - public int getCount(LevelAccessor world, EntityType entityType) { CachePerWorld cache = getOrCreateCache(world); return cache.getCount(entityType); @@ -113,15 +117,59 @@ public int getCountPerModAll(LevelAccessor world, String mod) { return countPerMod == null ? 0 : countPerMod.total; } -// public void registerSpawn(LevelAccessor world, EntityType entityType) { -// CachePerWorld cache = getOrCreateCache(world); -// cache.registerSpawn(entityType); -// } + // Since the counting with 'perlocal' is local and not global, we need new count functions. + // We pass minChunks and maxChunks as well as we're gonna count the entities between those boundaries. + public int getLocalCountAll(LevelAccessor world, Entity entity, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCountAll(entity, minChunks, maxChunks); + } + + public int getLocalCountPassive(LevelAccessor world, Entity entity, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCountPassive(entity, minChunks, maxChunks); + } + + public int getLocalCountHostile(LevelAccessor world, Entity entity, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCountHostile(entity, minChunks, maxChunks); + } + + public int getLocalCountNeutral(LevelAccessor world, Entity entity, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCountNeutral(entity, minChunks, maxChunks); + } + + public int getLocalCount(LevelAccessor world, Entity entity, EntityType entityType, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCount(entity, entityType, minChunks, maxChunks); + } + + public int getLocalNpcCount(LevelAccessor world, Entity entity, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalNpcCount(entity, minChunks, maxChunks); + } + + public int getLocalCountPerMod(LevelAccessor world, Entity entity, String mod, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCountPerMod(entity, mod, minChunks, maxChunks); + } + + public int getLocalCountPerModHostile(LevelAccessor world, Entity entity, String mod, int minChunks, + int maxChunks) { + return getOrCreateCache(world).getLocalCountPerModHostile(entity, mod, minChunks, maxChunks); + } + + public int getLocalCountPerModPassive(LevelAccessor world, Entity entity, String mod, int minChunks, + int maxChunks) { + return getOrCreateCache(world).getLocalCountPerModPassive(entity, mod, minChunks, maxChunks); + } -// public void registerDespawn(LevelAccessor world, EntityType entityType) { -// CachePerWorld cache = getOrCreateCache(world); -// cache.registerDespawn(entityType); -// } + public int getLocalCountPerModAll(LevelAccessor world, Entity entity, String mod, int minChunks, int maxChunks) { + return getOrCreateCache(world).getLocalCountPerModAll(entity, mod, minChunks, maxChunks); + } + + // public void registerSpawn(LevelAccessor world, EntityType entityType) { + // CachePerWorld cache = getOrCreateCache(world); + // cache.registerSpawn(entityType); + // } + + // public void registerDespawn(LevelAccessor world, EntityType entityType) { + // CachePerWorld cache = getOrCreateCache(world); + // cache.registerDespawn(entityType); + // } private CachePerWorld getOrCreateCache(LevelAccessor world) { ResourceKey key = Tools.getDimensionKey(world); @@ -133,7 +181,6 @@ private CachePerWorld getOrCreateCache(LevelAccessor world) { return cache; } - private static class CountPerMod { private int hostile; private int passive; @@ -142,6 +189,10 @@ private static class CountPerMod { } private static class CachePerWorld { + // One PlayerDistanceMap per registered chunk radius + private final Map distanceMaps = new HashMap<>(); + // For each radius, a map from each player to their PlayerLocalCounts + private final Map> localCountsByRadius = new HashMap<>(); private final Map cachedCounters = new HashMap<>(); private final Map cachedNpcCounters = new HashMap<>(); @@ -153,6 +204,25 @@ private static class CachePerWorld { private int validPlayers = -1; private int dirtyCounter = 0; + // Updates all registered PlayerDistanceMap instances for the current player positions and prunes the ones that are no longer needed. + private void tickPlayerDistanceMap(LevelAccessor world) { + ServerLevel sw = Tools.getServerWorld(world); + List players = sw.players(); + + Set radii = LocalDistanceRegistry.getRadii(); + + for (Integer radius : radii) { + distanceMaps.computeIfAbsent(radius, r -> new PlayerDistanceMap()).update(players, radius); + } + + distanceMaps.keySet().retainAll(radii); + localCountsByRadius.keySet().retainAll(radii); + + for (Map counts : localCountsByRadius.values()) { + counts.keySet().retainAll(players); + } + } + public int getValidSpawnChunks() { return validSpawnChunks; } @@ -196,6 +266,8 @@ private int countValidPlayers(LevelAccessor world) { } private void count(LevelAccessor world) { + tickPlayerDistanceMap(world); + dirtyCounter--; if (dirtyCounter > 0) { return; @@ -208,6 +280,7 @@ private void count(LevelAccessor world) { cachedCounters.clear(); countPerMod.clear(); cachedNpcCounters.clear(); + localCountsByRadius.clear(); countPassive = 0; countHostile = 0; countNeutral = 0; @@ -244,6 +317,9 @@ public void addCountedMob(Entity entity) { cachedNpcCounters.put(mob, cnt); } } + boolean isHostile = entity instanceof Enemy; + boolean isPassive = entity instanceof Animal; + updateLocalCounts(entity, mod, isHostile, isPassive, +1); } } @@ -282,10 +358,125 @@ public boolean removeCountedMob(Entity entity) { countNeutral--; } } + boolean isHostile = entity instanceof Enemy; + boolean isPassive = entity instanceof Animal; + updateLocalCounts(entity, mod, isHostile, isPassive, -1); } return true; } + // For a given entity, finds which players have it within each registered radius using the + // PlayerDistanceMap and adds/removes it to/from their PlayerLocalCounts for that radius. + private void updateLocalCounts(Entity entity, String mod, boolean isHostile, boolean isPassive, int delta) { + long chunkKey = entity.chunkPosition().toLong(); + for (Map.Entry entry : distanceMaps.entrySet()) { + PlayerDistanceMap map = entry.getValue(); + Map localCounts = localCountsByRadius.computeIfAbsent(entry.getKey(), r -> new HashMap<>()); + + for (ServerPlayer player : map.getPlayersInRange(chunkKey)) { + PlayerLocalCounts counts = localCounts.computeIfAbsent(player, p -> new PlayerLocalCounts()); + counts.perType.merge(entity.getType(), delta, Integer::sum); + CountPerMod cpm = counts.perMod.computeIfAbsent(mod, s -> new CountPerMod()); + cpm.total += delta; + if (isHostile) { + counts.hostile += delta; + cpm.hostile += delta; + } else if (isPassive) { + counts.passive += delta; + cpm.passive += delta; + } else { + counts.neutral += delta; + cpm.neutral += delta; + } + + if (ModSetup.customnpcs && CustomNPCSupport.hasNPCInterface(entity) && entity.getPersistentData().contains("InControlNatSpawnName") && entity.getPersistentData().contains("InControlNatSpawnTab")) { + CNPCMob mob = new CNPCMob(entity.getPersistentData().getInt("InControlNatSpawnTab"), entity.getPersistentData().getString("InControlNatSpawnName")); + counts.perNpc.merge(mob, delta, Integer::sum); + } + } + } + } + + // For a given entity and radius range, it looks at every player who has that entity's chunk within their max-radius, + // subtracts the min-radius count if applicable and returns the maximum across all nearby players. + // The max is used because we want to know the most crowded player's neighborhood. + private int getMaxLocal(Entity entity, int minChunks, int maxChunks, ToIntFunction field) { + PlayerDistanceMap maxMap = distanceMaps.get(maxChunks); + if (maxMap == null) { + return 0; + } + Map maxCounts = localCountsByRadius.get(maxChunks); + Map minCounts = minChunks >= 0 ? localCountsByRadius.get(minChunks) : null; + + long chunkKey = entity.chunkPosition().toLong(); + int best = 0; + for (ServerPlayer player : maxMap.getPlayersInRange(chunkKey)) { + PlayerLocalCounts maxC = maxCounts == null ? null : maxCounts.get(player); + int maxVal = maxC == null ? 0 : field.applyAsInt(maxC); + + int minVal = 0; + if (minCounts != null) { + PlayerLocalCounts minC = minCounts.get(player); + minVal = minC == null ? 0 : field.applyAsInt(minC); + } + best = Math.max(best, maxVal - minVal); + } + return best; + } + + public int getLocalCountAll(Entity entity, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> c.hostile + c.passive + c.neutral); + } + + public int getLocalCountPassive(Entity entity, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> c.passive); + } + + public int getLocalCountHostile(Entity entity, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> c.hostile); + } + + public int getLocalCountNeutral(Entity entity, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> c.neutral); + } + + public int getLocalCount(Entity entity, EntityType entityType, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> c.perType.getOrDefault(entityType, 0)); + } + + public int getLocalNpcCount(Entity entity, int minChunks, int maxChunks) { + if (ModSetup.customnpcs && CustomNPCSupport.hasNPCInterface(entity) && entity.getPersistentData().contains("InControlNatSpawnName") && entity.getPersistentData().contains("InControlNatSpawnTab")) { + CNPCMob mob = new CNPCMob( entity.getPersistentData().getInt("InControlNatSpawnTab"), entity.getPersistentData().getString("InControlNatSpawnName")); + return getMaxLocal(entity, minChunks, maxChunks, c -> c.perNpc.getOrDefault(mob, 0)); + } + return getLocalCount(entity, entity.getType(), minChunks, maxChunks); + } + + public int getLocalCountPerMod(Entity entity, String mod, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> { + CountPerMod m = c.perMod.get(mod); + return m == null ? 0 : m.total; + }); + } + + public int getLocalCountPerModHostile(Entity entity, String mod, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> { + CountPerMod m = c.perMod.get(mod); + return m == null ? 0 : m.hostile; + }); + } + + public int getLocalCountPerModPassive(Entity entity, String mod, int minChunks, int maxChunks) { + return getMaxLocal(entity, minChunks, maxChunks, c -> { + CountPerMod m = c.perMod.get(mod); + return m == null ? 0 : m.passive; + }); + } + + public int getLocalCountPerModAll(Entity entity, String mod, int minChunks, int maxChunks) { + return getLocalCountPerMod(entity, mod, minChunks, maxChunks); + } + public int getCount(EntityType entityType) { return cachedCounters.getOrDefault(entityType, 0); } @@ -314,6 +505,17 @@ public int getNpcCount(Entity entity) { // cachedCounters.put(entityType, cnt-1); // } // } + + // Per-player, per-radius counter storage + // Stores category totals, a per-EntityType map, a per-mod map and a per-npc map. + private static class PlayerLocalCounts { + private int hostile; + private int passive; + private int neutral; + private final Map perType = new HashMap<>(); + private final Map perMod = new HashMap<>(); + private final Map perNpc = new HashMap<>(); + } } } diff --git a/src/main/java/mcjty/incontrol/tools/distance/PlayerDistanceMap.java b/src/main/java/mcjty/incontrol/tools/distance/PlayerDistanceMap.java new file mode 100644 index 0000000..0e6941f --- /dev/null +++ b/src/main/java/mcjty/incontrol/tools/distance/PlayerDistanceMap.java @@ -0,0 +1,176 @@ +package mcjty.incontrol.tools.distance; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMaps; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.ChunkPos; + + +/** + * Tracks which players are within a given chunk radius of every chunk in the world. + * This is a direct port of the player distance map from Paper's 'per-player mob spawns' (https://github.com/PaperMC/Paper/pull/2171). + */ +public final class PlayerDistanceMap { + private static final PooledHashSets.PooledObjectLinkedOpenHashSet EMPTY_SET = new PooledHashSets.PooledObjectLinkedOpenHashSet<>(); + + // Map of each tracked player to they last known ChunkPos + private final Map players = new HashMap<>(); + + // Maps each chunk to the pooled set of players within range of it, sinchronized for thread safety. + private final Long2ObjectOpenHashMap> playerMapUnsync = new Long2ObjectOpenHashMap<>(1024, 0.5f); + private final Long2ObjectMap> playerMap = Long2ObjectMaps.synchronize(playerMapUnsync); + + // Chunk radius + private int viewDistance; + + // Pool of deduplicated sets + private final PooledHashSets pooledHashSets = new PooledHashSets<>(); + + // Gets players within range of a given chunk or empty (given chunk key) + public PooledHashSets.PooledObjectLinkedOpenHashSet getPlayersInRange(final long chunkKey) { + return this.playerMap.getOrDefault(chunkKey, EMPTY_SET); + } + + // Gets players within range of a given chunk or empty (given chunk pos) + public PooledHashSets.PooledObjectLinkedOpenHashSet getPlayersInRange(final ChunkPos pos) { + return getPlayersInRange(pos.toLong()); + } + + // Called every tick with the current player list and view distance. + // - Figures out which players have left. + // - For each current player, compares their new chunk position to their old one. + // - Calls addNewPlayer, updatePlayer, or removePlayer as appropriate. + // - Cleans up players that are no longer online. + public void update(final List currentPlayers, final int newViewDistance) { + final ObjectLinkedOpenHashSet gone = new ObjectLinkedOpenHashSet<>(this.players.keySet()); + + final int oldViewDistance = this.viewDistance; + this.viewDistance = newViewDistance; + + for (final ServerPlayer player : currentPlayers) { + if (player.isSpectator()) + continue; + + gone.remove(player); + + final ChunkPos newPosition = player.chunkPosition(); + final ChunkPos oldPosition = this.players.put(player, newPosition); + + if (oldPosition == null) { + addNewPlayer(player, newPosition, newViewDistance); + } else { + updatePlayer(player, oldPosition, newPosition, oldViewDistance, newViewDistance); + } + } + + for (final ServerPlayer player : gone) { + final ChunkPos oldPosition = this.players.remove(player); + if (oldPosition != null) { + removePlayer(player, oldPosition, oldViewDistance); + } + } + } + + // Adds a player to a chunk's player set + private void addPlayerTo(final ServerPlayer player, final int chunkX, final int chunkZ) { + this.playerMap.compute(ChunkPos.asLong(chunkX, chunkZ), (key, set) -> set == null + ? new PooledHashSets.PooledObjectLinkedOpenHashSet<>(player) + : this.pooledHashSets.findMapWith(set, player)); + } + + // Guess + private void removePlayerFrom(final ServerPlayer player, final int chunkX, final int chunkZ) { + this.playerMap.compute(ChunkPos.asLong(chunkX, chunkZ), (key, set) -> { + if (set == null) + return null; + return this.pooledHashSets.findMapWithout(set, player); + }); + } + + // Instead of removing the player from all old chunks and re-adding to all new chunks, + // we compute only the chunks that were added to the view and the chunks that fell out of it. + // We use the direction of movement to iterate only the newly covered and newly uncovered strips of chunks. + // If the movement is large enough that the old and new view areas don't overlap, it falls back to a full remove + add. + private void updatePlayer(final ServerPlayer player, final ChunkPos oldPosition, final ChunkPos newPosition, final int oldViewDistance, final int newViewDistance) { + final int toX = newPosition.x; + final int toZ = newPosition.z; + final int fromX = oldPosition.x; + final int fromZ = oldPosition.z; + + final int dx = toX - fromX; + final int dz = toZ - fromZ; + + if (Math.max(Math.abs(dx), Math.abs(dz)) >= (2 * oldViewDistance) || oldViewDistance != newViewDistance) { + removePlayer(player, oldPosition, oldViewDistance); + addNewPlayer(player, newPosition, newViewDistance); + return; + } + + final int up = 1 | (dz >> (Integer.SIZE - 1)); + final int right = 1 | (dx >> (Integer.SIZE - 1)); + + int maxX, minX, maxZ, minZ; + + if (dx != 0) { + maxX = toX + (oldViewDistance * right) + right; + minX = fromX + (oldViewDistance * right) + right; + maxZ = fromZ + (oldViewDistance * up) + up; + minZ = toZ - (oldViewDistance * up); + for (int x = minX; x != maxX; x += right) + for (int z = minZ; z != maxZ; z += up) + addPlayerTo(player, x, z); + } + if (dz != 0) { + maxX = toX + (oldViewDistance * right) + right; + minX = toX - (oldViewDistance * right); + maxZ = toZ + (oldViewDistance * up) + up; + minZ = fromZ + (oldViewDistance * up) + up; + for (int x = minX; x != maxX; x += right) + for (int z = minZ; z != maxZ; z += up) + addPlayerTo(player, x, z); + } + if (dx != 0) { + maxX = toX - (oldViewDistance * right); + minX = fromX - (oldViewDistance * right); + maxZ = fromZ + (oldViewDistance * up) + up; + minZ = toZ - (oldViewDistance * up); + for (int x = minX; x != maxX; x += right) + for (int z = minZ; z != maxZ; z += up) + removePlayerFrom(player, x, z); + } + if (dz != 0) { + maxX = fromX + (oldViewDistance * right) + right; + minX = fromX - (oldViewDistance * right); + maxZ = toZ - (oldViewDistance * up); + minZ = fromZ - (oldViewDistance * up); + for (int x = minX; x != maxX; x += right) + for (int z = minZ; z != maxZ; z += up) + removePlayerFrom(player, x, z); + } + } + + // Registers a player across all chunks in their view distance square. + // (2 * viewDistance + 1) by (2 * viewDistance + 1) grid centered on the player. + private void addNewPlayer(final ServerPlayer player, final ChunkPos position, final int viewDistance) { + final int x = position.x; + final int z = position.z; + for (int xoff = -viewDistance; xoff <= viewDistance; ++xoff) + for (int zoff = -viewDistance; zoff <= viewDistance; ++zoff) + addPlayerTo(player, x + xoff, z + zoff); + } + + // Unregisters a player from all chunks in their view distance square. + private void removePlayer(final ServerPlayer player, final ChunkPos position, final int viewDistance) { + final int x = position.x; + final int z = position.z; + for (int xoff = -viewDistance; xoff <= viewDistance; ++xoff) + for (int zoff = -viewDistance; zoff <= viewDistance; ++zoff) + removePlayerFrom(player, x + xoff, z + zoff); + } +} \ No newline at end of file diff --git a/src/main/java/mcjty/incontrol/tools/distance/PooledHashSets.java b/src/main/java/mcjty/incontrol/tools/distance/PooledHashSets.java new file mode 100644 index 0000000..5d988bb --- /dev/null +++ b/src/main/java/mcjty/incontrol/tools/distance/PooledHashSets.java @@ -0,0 +1,223 @@ +package mcjty.incontrol.tools.distance; + +import java.lang.ref.WeakReference; +import java.util.Iterator; + +import org.jetbrains.annotations.NotNull; + +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.objects.ObjectLinkedOpenHashSet; + +/** + * This is a memory-efficient data structure that maintains a pool of deduplicated sets. + * Instead of creating a new HashSet every time a player is added or removed from a chunk's tracking, it reuses existing set instances that have the same contents. + * This is a direct port of the pooled hash sets from Paper's 'per-player mob spawns' (https://github.com/PaperMC/Paper/pull/2171). + */ +public class PooledHashSets { + // A map from set to the same set used as a lookup table. Since the key and value are the same object, + // it's essentially a HashSet that lets you retrieve the existing instance by value equality. + protected final Object2ObjectOpenHashMap, PooledObjectLinkedOpenHashSet> mapPool = new Object2ObjectOpenHashMap<>(64, 0.25f); + + // Reduces the reference count of a set. When it hits 0, the set is removed from the pool since nothing references it anymore. + // The -1 is a special case that marks sets that are "permanent" and should never be deleted. + protected void decrementReferenceCount(final PooledObjectLinkedOpenHashSet current) { + if (current.referenceCount == 0) { + throw new IllegalStateException("Cannot decrement reference count for " + current); + } + if (current.referenceCount == -1 || --current.referenceCount > 0) { + return; + } + this.mapPool.remove(current); + } + + // Returns a set that is identical to 'current' but with the object added. + // - Checks the add cache first. + // - Adds the element to 'current' temporarily to see how the new set would look like. + // - If found in the pool, reuses it, if not, creates a new entry. + // - Restores 'current' to its original state, updates the cache and reduces 'current''s reference count since the caller is moving to the new set + public PooledObjectLinkedOpenHashSet findMapWith(final PooledObjectLinkedOpenHashSet current, final E object) { + final PooledObjectLinkedOpenHashSet cached = current.getAddCache(object); + if (cached != null) { + if (cached.referenceCount != -1) + ++cached.referenceCount; + decrementReferenceCount(current); + return cached; + } + + if (!current.add(object)) { + return current; + } + + PooledObjectLinkedOpenHashSet ret = this.mapPool.get(current); + if (ret == null) { + ret = new PooledObjectLinkedOpenHashSet<>(current); + current.remove(object); + this.mapPool.put(ret, ret); + ret.referenceCount = 1; + } else { + if (ret.referenceCount != -1) + ++ret.referenceCount; + current.remove(object); + } + + current.updateAddCache(object, ret); + decrementReferenceCount(current); + return ret; + } + + // Returns a set that is identical to 'current' but with the object removed. + public PooledObjectLinkedOpenHashSet findMapWithout(final PooledObjectLinkedOpenHashSet current, final E object) { + if (current.set.size() == 1) { + decrementReferenceCount(current); + return null; + } + + final PooledObjectLinkedOpenHashSet cached = current.getRemoveCache(object); + if (cached != null) { + if (cached.referenceCount != -1) + ++cached.referenceCount; + decrementReferenceCount(current); + return cached; + } + + if (!current.remove(object)) { + return current; + } + + PooledObjectLinkedOpenHashSet ret = this.mapPool.get(current); + if (ret == null) { + ret = new PooledObjectLinkedOpenHashSet<>(current); + current.add(object); + this.mapPool.put(ret, ret); + ret.referenceCount = 1; + } else { + if (ret.referenceCount != -1) + ++ret.referenceCount; + current.add(object); + } + + current.updateRemoveCache(object, ret); + decrementReferenceCount(current); + return ret; + } + + // Set wrapper. + public static final class PooledObjectLinkedOpenHashSet implements Iterable { + + private static final WeakReference NULL_REFERENCE = new WeakReference(null); + + // Contains the actual elements. + final ObjectLinkedOpenHashSet set; + // How many things are pointing to this set instance. -1 means its permanent and never deleted. + int referenceCount; + // A running XOR-style hash maintained incrementally so equality checks are fast. + int hash; + + // One-entry caches stored as WeakReferences so they don't prevent garbage collection. + WeakReference lastAddObject = NULL_REFERENCE; + WeakReference> lastAddMap = NULL_REFERENCE; + WeakReference lastRemoveObject = NULL_REFERENCE; + WeakReference> lastRemoveMap = NULL_REFERENCE; + + public PooledObjectLinkedOpenHashSet() { + this.set = new ObjectLinkedOpenHashSet<>(2, 0.6f); + } + + public PooledObjectLinkedOpenHashSet(final E single) { + this(); + this.referenceCount = -1; + this.add(single); + } + + public PooledObjectLinkedOpenHashSet(final PooledObjectLinkedOpenHashSet other) { + this.set = other.set.clone(); + this.hash = other.hash; + } + + // Fast integer mixing function used to compute each element's contribution to the set's hash. + // Using an additive hash makes it so that the overall hash stays correct without recomputing from scratch. + static int hash0(int x) { + x *= 0x36935555; + x ^= x >>> 16; + return x; + } + + // Checks if the last add operation involved the same element and if the resulting set is still alive. + // If so, returns it directly and skips the pool lookup entirely. + public PooledObjectLinkedOpenHashSet getAddCache(final E element) { + final E currentAdd = this.lastAddObject.get(); + if (currentAdd == null || !(currentAdd == element || currentAdd.equals(element))) + return null; + final PooledObjectLinkedOpenHashSet map = this.lastAddMap.get(); + if (map == null || map.referenceCount == 0) + return null; + return map; + } + + // Checks if the last remove operation involved the same element and if the resulting set is still alive. + // If so, returns it directly and skips the pool lookup entirely. + public PooledObjectLinkedOpenHashSet getRemoveCache(final E element) { + final E currentRemove = this.lastRemoveObject.get(); + if (currentRemove == null || !(currentRemove == element || currentRemove.equals(element))) + return null; + final PooledObjectLinkedOpenHashSet map = this.lastRemoveMap.get(); + if (map == null || map.referenceCount == 0) + return null; + return map; + } + + // Stores the result of an add operation into the one-entry cache. + public void updateAddCache(final E element, final PooledObjectLinkedOpenHashSet map) { + this.lastAddObject = new WeakReference<>(element); + this.lastAddMap = new WeakReference<>(map); + } + + // // Stores the result of a remove operation into the one-entry cache. + public void updateRemoveCache(final E element, final PooledObjectLinkedOpenHashSet map) { + this.lastRemoveObject = new WeakReference<>(element); + this.lastRemoveMap = new WeakReference<>(map); + } + + // Adds an element to a set + boolean add(final E element) { + boolean added = this.set.add(element); + if (added) + this.hash += hash0(element.hashCode()); + return added; + } + + // Removes an element from a set + boolean remove(E element) { + boolean removed = this.set.remove(element); + if (removed) + this.hash -= hash0(element.hashCode()); + return removed; + } + + @Override + public @NotNull Iterator iterator() { + return this.set.iterator(); + } + + @Override + public int hashCode() { + return this.hash; + } + + // We add a special case where if referenceCount == 0, then the set is being used as a temporary set + // inside findMapWith/Without, so it uses identity equality instead of full equality. + @Override + public boolean equals(final Object other) { + if (!(other instanceof PooledObjectLinkedOpenHashSet)) + return false; + if (this.referenceCount == 0) { + return other == this; + } else { + if (other == this) + return false; + return this.hash == ((PooledObjectLinkedOpenHashSet) other).hash + && this.set.equals(((PooledObjectLinkedOpenHashSet) other).set); + } + } + } +} \ No newline at end of file From afc50ac22d053352743a3bc19db9f6b9dab6c2da Mon Sep 17 00:00:00 2001 From: PizzaConBacon Date: Tue, 30 Jun 2026 01:39:38 +0100 Subject: [PATCH 2/3] Cleanup --- .vscode/settings.json | 3 +++ .../mcjty/incontrol/rules/support/CountInfo.java | 8 ++++++++ .../mcjty/incontrol/rules/support/RuleCache.java | 15 +++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..849f79e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} diff --git a/src/main/java/mcjty/incontrol/rules/support/CountInfo.java b/src/main/java/mcjty/incontrol/rules/support/CountInfo.java index 418c679..e671da5 100644 --- a/src/main/java/mcjty/incontrol/rules/support/CountInfo.java +++ b/src/main/java/mcjty/incontrol/rules/support/CountInfo.java @@ -24,6 +24,14 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.level.LevelAccessor; +import org.apache.commons.lang3.StringUtils; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; class CountInfo { public int amount; diff --git a/src/main/java/mcjty/incontrol/rules/support/RuleCache.java b/src/main/java/mcjty/incontrol/rules/support/RuleCache.java index f5bd99a..602be45 100644 --- a/src/main/java/mcjty/incontrol/rules/support/RuleCache.java +++ b/src/main/java/mcjty/incontrol/rules/support/RuleCache.java @@ -25,6 +25,9 @@ import net.minecraft.world.level.Level; import net.minecraft.world.level.LevelAccessor; +import java.util.HashMap; +import java.util.Map; + public class RuleCache { private final Map, CachePerWorld> caches = new HashMap<>(); @@ -83,6 +86,7 @@ public int getCountNeutral(LevelAccessor world) { return cache.getCountNeutral(); } + public int getCount(LevelAccessor world, EntityType entityType) { CachePerWorld cache = getOrCreateCache(world); return cache.getCount(entityType); @@ -117,6 +121,16 @@ public int getCountPerModAll(LevelAccessor world, String mod) { return countPerMod == null ? 0 : countPerMod.total; } +// public void registerSpawn(LevelAccessor world, EntityType entityType) { +// CachePerWorld cache = getOrCreateCache(world); +// cache.registerSpawn(entityType); +// } + +// public void registerDespawn(LevelAccessor world, EntityType entityType) { +// CachePerWorld cache = getOrCreateCache(world); +// cache.registerDespawn(entityType); +// } + // Since the counting with 'perlocal' is local and not global, we need new count functions. // We pass minChunks and maxChunks as well as we're gonna count the entities between those boundaries. public int getLocalCountAll(LevelAccessor world, Entity entity, int minChunks, int maxChunks) { @@ -181,6 +195,7 @@ private CachePerWorld getOrCreateCache(LevelAccessor world) { return cache; } + private static class CountPerMod { private int hostile; private int passive; From cf769ed287ac11a1bbb887c1b51b81a5a3e47fbe Mon Sep 17 00:00:00 2001 From: Edwin <51492116+PizzaConBacon@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:18:46 +0100 Subject: [PATCH 3/3] More cleanup --- .vscode/settings.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 849f79e..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "java.compile.nullAnalysis.mode": "automatic" -}