From 15fcd14e16847b749ebe8112d1477c0e5d61bb64 Mon Sep 17 00:00:00 2001 From: xGinko Date: Fri, 6 Sep 2024 01:46:18 +0200 Subject: [PATCH] add experimental regional optimization --- .../config/LanguageCache.java | 5 + .../optimization/OptimizeByActivity.java | 240 ++++++++++++++++++ .../optimization/OptimizeByNametag.java | 6 +- .../struct/enums/OptimizationType.java | 2 + .../struct/models/BlockRegion2D.java | 131 ++++++++++ .../wrapper/WrappedVillager.java | 8 +- 6 files changed, 387 insertions(+), 5 deletions(-) create mode 100644 src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByActivity.java create mode 100644 src/main/java/me/xginko/villageroptimizer/struct/models/BlockRegion2D.java diff --git a/src/main/java/me/xginko/villageroptimizer/config/LanguageCache.java b/src/main/java/me/xginko/villageroptimizer/config/LanguageCache.java index b3549a7..72b76d0 100644 --- a/src/main/java/me/xginko/villageroptimizer/config/LanguageCache.java +++ b/src/main/java/me/xginko/villageroptimizer/config/LanguageCache.java @@ -20,6 +20,7 @@ public class LanguageCache { public final @NotNull List nametag_optimize_success, nametag_on_optimize_cooldown, nametag_unoptimize_success, block_optimize_success, block_on_optimize_cooldown, block_unoptimize_success, workstation_optimize_success, workstation_on_optimize_cooldown, workstation_unoptimize_success, + activity_optimize_success, command_optimize_success, command_radius_limit_exceed, command_optimize_fail, command_unoptimize_success, command_specify_radius, command_radius_invalid, command_no_villagers_nearby, trades_restocked, optimize_for_trading, villager_leveling_up; @@ -67,6 +68,10 @@ public class LanguageCache { "You need to wait %time% until you can optimize this villager again."); this.workstation_unoptimize_success = getListTranslation("messages.workstation.unoptimize-success", "Successfully unoptimized %villagertype% villager by removing workstation block %blocktype%."); + // Activity + this.activity_optimize_success = getListTranslation("messages.activity.optimized-near-you", + "%amount% villagers close to you were automatically optimized due to high activity."); + // Command this.command_optimize_success = getListTranslation("messages.command.optimize-success", "Successfully optimized %amount% villager(s) in a radius of %radius% blocks."); diff --git a/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByActivity.java b/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByActivity.java new file mode 100644 index 0000000..28009f4 --- /dev/null +++ b/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByActivity.java @@ -0,0 +1,240 @@ +package me.xginko.villageroptimizer.modules.optimization; + +import com.cryptomorin.xseries.XEntityType; +import com.destroystokyo.paper.event.entity.EntityPathfindEvent; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import me.xginko.villageroptimizer.VillagerOptimizer; +import me.xginko.villageroptimizer.modules.VillagerOptimizerModule; +import me.xginko.villageroptimizer.struct.enums.OptimizationType; +import me.xginko.villageroptimizer.struct.models.BlockRegion2D; +import me.xginko.villageroptimizer.wrapper.WrappedVillager; +import net.kyori.adventure.text.TextReplacementConfig; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityInteractEvent; +import org.jetbrains.annotations.NotNull; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +public class OptimizeByActivity extends VillagerOptimizerModule implements Listener { + + protected static class RegionData { + + public final BlockRegion2D region; + public final AtomicInteger pathfindCount, entityInteractCount; + public final AtomicBoolean regionBusy; + + public RegionData(BlockRegion2D region) { + this.region = region; + this.pathfindCount = new AtomicInteger(); + this.entityInteractCount = new AtomicInteger(); + this.regionBusy = new AtomicBoolean(false); + } + } + + private final Cache regionDataCache; + private final double checkRadius; + private final int pathfindLimit, entityInteractLimit; + private final boolean notifyPlayers, doLogging; + + public OptimizeByActivity() { + super("optimization-methods.regional-activity"); + config.master().addComment(configPath + ".enable", + "Enable optimization by naming villagers to one of the names configured below.\n" + + "Nametag optimized villagers will be unoptimized again when they are renamed to something else."); + + this.checkRadius = config.getDouble(configPath + ".check-radius-blocks", 500.0, + "The radius in blocks in which activity will be grouped together and measured."); + this.regionDataCache = Caffeine.newBuilder().expireAfterWrite(Duration.ofMillis( + config.getInt(configPath + ".data-keep-time-millis", 10000, + "The time in milliseconds before a region and its data will be expired\n" + + "if no activity has been detected.\n" + + "For proper functionality, needs to be at least as long as your pause time."))).build(); + + this.pathfindLimit = config.getInt(configPath + ".limits.pathfind-event", 150); + this.entityInteractLimit = config.getInt(configPath + ".limits.interact-event", 50); + + this.notifyPlayers = config.getBoolean(configPath + ".notify-players", true, + "Sends players a message to any player near an auto-optimized villager."); + this.doLogging = config.getBoolean(configPath + ".log", false); + } + + @Override + public void enable() { + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @Override + public void disable() { + HandlerList.unregisterAll(this); + } + + @Override + public boolean shouldEnable() { + return config.getBoolean(configPath + ".enable", false); + } + + private @NotNull RegionData getRegionData(Location location) { + return regionDataCache.get(getRegion(location), RegionData::new); + } + + private @NotNull BlockRegion2D getRegion(Location location) { + // Find and return region containing this location + for (Map.Entry regionDataEntry : regionDataCache.asMap().entrySet()) { + if (regionDataEntry.getKey().contains(location)) { + return regionDataEntry.getKey(); + } + } + + // Create and cache region if none exists + BlockRegion2D region = BlockRegion2D.of(location.getWorld(), location.getX(), location.getZ(), checkRadius); + regionDataCache.put(region, new RegionData(region)); + return region; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + private void onEntityPathfind(EntityPathfindEvent event) { + if (event.getEntityType() != XEntityType.VILLAGER.get()) return; + + Location location = event.getEntity().getLocation(); + BlockRegion2D region2D = getRegion(location); + RegionData regionData = getRegionData(location); + + if (regionData.regionBusy.get() || regionData.pathfindCount.incrementAndGet() <= pathfindLimit) { + return; + } + + regionData.regionBusy.set(true); + + AtomicInteger optimizeCount = new AtomicInteger(); + Set playersWithinArea = new CopyOnWriteArraySet<>(); + + region2D.getEntities() + .thenAccept(entities -> { + for (Entity entity : entities) { + scheduling.entitySpecificScheduler(entity).run(() -> { + if (entity.getType() == XEntityType.VILLAGER.get()) { + WrappedVillager wrappedVillager = wrapperCache.get((Villager) entity, WrappedVillager::new); + + if (wrappedVillager.isOptimized()) { + return; + } + + wrappedVillager.setOptimizationType(OptimizationType.REGIONAL_ACTIVITY); + optimizeCount.incrementAndGet(); + } + + if (notifyPlayers && entity.getType() == XEntityType.PLAYER.get()) { + playersWithinArea.add((Player) entity); + } + }, null); + } + }) + .thenRun(() -> { + if (notifyPlayers) { + TextReplacementConfig amount = TextReplacementConfig.builder() + .matchLiteral("%amount%") + .replacement(optimizeCount.toString()) + .build(); + + for (Player player : playersWithinArea) { + VillagerOptimizer.scheduling().entitySpecificScheduler(player).run(() -> + VillagerOptimizer.getLang(player.locale()).activity_optimize_success + .forEach(line -> player.sendMessage(line.replaceText(amount))), + null); + } + + playersWithinArea.clear(); + } + + if (doLogging) { + info( "Optimized " + optimizeCount.get() + " villagers in a radius of " + checkRadius + + " blocks from center at x=" + regionData.region.getCenterX() + ", z=" + regionData.region.getCenterZ() + + " in world " + location.getWorld().getName() + + "because of too high pathfinding activity within the configured timeframe: " + + regionData.pathfindCount + " (limit: " + pathfindLimit + ")"); + } + + regionDataCache.invalidate(region2D); + }); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + private void onEntityInteract(EntityInteractEvent event) { + if (event.getEntityType() != XEntityType.VILLAGER.get()) return; + + Location location = event.getEntity().getLocation(); + BlockRegion2D region2D = getRegion(location); + RegionData regionData = getRegionData(location); + + if (regionData.regionBusy.get() || regionData.entityInteractCount.incrementAndGet() <= entityInteractLimit) { + return; + } + + regionData.regionBusy.set(true); + + AtomicInteger optimizeCount = new AtomicInteger(); + Set playersWithinArea = new CopyOnWriteArraySet<>(); + + region2D.getEntities() + .thenAccept(entities -> { + for (Entity entity : entities) { + scheduling.entitySpecificScheduler(entity).run(() -> { + if (entity.getType() == XEntityType.VILLAGER.get()) { + WrappedVillager wrappedVillager = wrapperCache.get((Villager) entity, WrappedVillager::new); + + if (wrappedVillager.isOptimized()) { + return; + } + + wrappedVillager.setOptimizationType(OptimizationType.REGIONAL_ACTIVITY); + optimizeCount.incrementAndGet(); + } + + if (notifyPlayers && entity.getType() == XEntityType.PLAYER.get()) { + playersWithinArea.add((Player) entity); + } + }, null); + } + }) + .thenRun(() -> { + if (notifyPlayers) { + TextReplacementConfig amount = TextReplacementConfig.builder() + .matchLiteral("%amount%") + .replacement(optimizeCount.toString()) + .build(); + + for (Player player : playersWithinArea) { + VillagerOptimizer.scheduling().entitySpecificScheduler(player).run(() -> + VillagerOptimizer.getLang(player.locale()).activity_optimize_success + .forEach(line -> player.sendMessage(line.replaceText(amount))), + null); + } + + playersWithinArea.clear(); + } + + if (doLogging) { + info( "Optimized " + optimizeCount.get() + " villagers in a radius of " + checkRadius + + " blocks from center at x=" + regionData.region.getCenterX() + ", z=" + regionData.region.getCenterZ() + + " in world " + location.getWorld().getName() + + "because of too many villagers interacting with objects within the configured timeframe: " + + regionData.pathfindCount + " (limit: " + pathfindLimit + ")"); + } + + regionDataCache.invalidate(region2D); + }); + } +} \ No newline at end of file diff --git a/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByNametag.java b/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByNametag.java index 5cbf3ff..1f61ca1 100644 --- a/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByNametag.java +++ b/src/main/java/me/xginko/villageroptimizer/modules/optimization/OptimizeByNametag.java @@ -99,13 +99,13 @@ public class OptimizeByNametag extends VillagerOptimizerModule implements Listen if (!optimizeEvent.callEvent()) return; + wrapped.setOptimizationType(optimizeEvent.getOptimizationType()); + wrapped.saveOptimizeTime(); + if (!consume_nametag && player.getGameMode() == GameMode.SURVIVAL) { player.getInventory().addItem(usedItem.asOne()); } - wrapped.setOptimizationType(optimizeEvent.getOptimizationType()); - wrapped.saveOptimizeTime(); - if (notify_player) { VillagerOptimizer.getLang(player.locale()).nametag_optimize_success .forEach(line -> KyoriUtil.sendMessage(player, line)); diff --git a/src/main/java/me/xginko/villageroptimizer/struct/enums/OptimizationType.java b/src/main/java/me/xginko/villageroptimizer/struct/enums/OptimizationType.java index d975180..eb360c1 100644 --- a/src/main/java/me/xginko/villageroptimizer/struct/enums/OptimizationType.java +++ b/src/main/java/me/xginko/villageroptimizer/struct/enums/OptimizationType.java @@ -1,6 +1,8 @@ package me.xginko.villageroptimizer.struct.enums; public enum OptimizationType { + CHUNK_LIMIT, + REGIONAL_ACTIVITY, COMMAND, NAMETAG, WORKSTATION, diff --git a/src/main/java/me/xginko/villageroptimizer/struct/models/BlockRegion2D.java b/src/main/java/me/xginko/villageroptimizer/struct/models/BlockRegion2D.java new file mode 100644 index 0000000..0a0ee2d --- /dev/null +++ b/src/main/java/me/xginko/villageroptimizer/struct/models/BlockRegion2D.java @@ -0,0 +1,131 @@ +package me.xginko.villageroptimizer.struct.models; + +import me.xginko.villageroptimizer.VillagerOptimizer; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Entity; + +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public class BlockRegion2D { + + private final UUID worldUID; + private final double halfSideLength, centerX, centerZ; + + /** + * A square region on a minecraft world map. + * + * @param worldUID The UUID of the world this region is in. + * @param centerX The X-axis of the center location on the map. + * @param centerZ The Z-axis of the center location on the map. + * @param halfSideLength Half the length of the square's side. Acts like a radius would on circular regions. + */ + public BlockRegion2D(UUID worldUID, double centerX, double centerZ, double halfSideLength) { + this.worldUID = worldUID; + this.centerX = centerX; + this.centerZ = centerZ; + this.halfSideLength = halfSideLength; + } + + /** + * Creates a square region on a minecraft world map. + * + * @param worldUID The UUID of the world this region is in. + * @param centerX The X-axis of the center location on the map. + * @param centerZ The Z-axis of the center location on the map. + * @param halfSideLength Half the length of the square's side. Acts like a radius would on circular regions. + */ + public static BlockRegion2D of(UUID worldUID, double centerX, double centerZ, double halfSideLength) { + return new BlockRegion2D(worldUID, centerX, centerZ, halfSideLength); + } + + /** + * Creates a square region on a minecraft world map. + * + * @param world The world this region is in. + * @param centerX The X-axis of the center location on the map. + * @param centerZ The Z-axis of the center location on the map. + * @param halfSideLength Half the length of the square's side. Acts like a radius would on circular regions. + */ + public static BlockRegion2D of(World world, double centerX, double centerZ, double halfSideLength) { + return BlockRegion2D.of(world.getUID(), centerX, centerZ, halfSideLength); + } + + public UUID getWorldUID() { + return this.worldUID; + } + + public double getHalfSideLength() { + return this.halfSideLength; + } + + public double getCenterX() { + return this.centerX; + } + + public double getCenterZ() { + return this.centerZ; + } + + public boolean contains(Location location) { + if (!location.getWorld().getUID().equals(this.worldUID)) { + return false; + } + + return location.getX() >= this.centerX - this.halfSideLength + && location.getX() <= this.centerX + this.halfSideLength + && location.getZ() >= this.centerZ - this.halfSideLength + && location.getZ() <= this.centerZ + this.halfSideLength; + } + + public CompletableFuture> getEntities() { + World world = Bukkit.getWorld(worldUID); + + if (world == null) { + // Only way I can imagine this happening would be if the server is using a world manager plugin and unloads + // the world during an operation. + // Since these plugins are rather common though, we will silently complete with an empty set instead of exceptionally. + return CompletableFuture.completedFuture(Collections.emptySet()); + } + + CompletableFuture> future = new CompletableFuture<>(); + Location centerLoc = new Location(world, centerX, world.getMinHeight(), centerZ); + + VillagerOptimizer.scheduling().regionSpecificScheduler(centerLoc).run(() -> future.complete( + centerLoc.getNearbyEntities( + halfSideLength, + Math.abs(world.getMaxHeight()) + Math.abs(world.getMinHeight()), // World y can be between -64 and 320, we want everything from top to bottom + halfSideLength + ))); + + return future; + } + + @Override + public boolean equals(Object obj) { + if (null == obj || obj.getClass() != BlockRegion2D.class) + return false; + BlockRegion2D blockRegion2D = (BlockRegion2D)obj; + return blockRegion2D.worldUID.equals(this.worldUID) && blockRegion2D.centerX == this.centerX && blockRegion2D.centerZ == this.centerZ; + } + + @Override + public int hashCode() { + return Objects.hash(this.worldUID, this.centerX, this.centerZ, this.halfSideLength); + } + + @Override + public String toString() { + return "BlockRegion2D{" + + " radius(half side length)=" + halfSideLength + + ", centerX=" + centerX + + ", centerZ=" + centerZ + + ", worldUID=" + worldUID + + "}"; + } +} diff --git a/src/main/java/me/xginko/villageroptimizer/wrapper/WrappedVillager.java b/src/main/java/me/xginko/villageroptimizer/wrapper/WrappedVillager.java index 22d669d..afffab6 100644 --- a/src/main/java/me/xginko/villageroptimizer/wrapper/WrappedVillager.java +++ b/src/main/java/me/xginko/villageroptimizer/wrapper/WrappedVillager.java @@ -7,6 +7,7 @@ import org.bukkit.Location; import org.bukkit.Sound; import org.bukkit.entity.Villager; import org.bukkit.entity.memory.MemoryKey; +import org.bukkit.event.entity.VillagerReplenishTradeEvent; import org.bukkit.inventory.MerchantRecipe; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -41,8 +42,11 @@ public class WrappedVillager extends PDCWrapper { */ public void restock() { VillagerOptimizer.scheduling().entitySpecificScheduler(villager).run(() -> { - for (MerchantRecipe recipe : villager.getRecipes()) { - recipe.setUses(0); + for (MerchantRecipe merchantRecipe : villager.getRecipes()) { + VillagerReplenishTradeEvent restockRecipeEvent = new VillagerReplenishTradeEvent(villager, merchantRecipe); + if (restockRecipeEvent.callEvent()) { + restockRecipeEvent.getRecipe().setUses(0); + } } }, null); }