add experimental regional optimization

This commit is contained in:
xGinko 2024-09-06 01:46:18 +02:00
parent 4019afe89b
commit 15fcd14e16
6 changed files with 387 additions and 5 deletions

View File

@ -20,6 +20,7 @@ public class LanguageCache {
public final @NotNull List<Component> nametag_optimize_success, nametag_on_optimize_cooldown, nametag_unoptimize_success, public final @NotNull List<Component> nametag_optimize_success, nametag_on_optimize_cooldown, nametag_unoptimize_success,
block_optimize_success, block_on_optimize_cooldown, block_unoptimize_success, block_optimize_success, block_on_optimize_cooldown, block_unoptimize_success,
workstation_optimize_success, workstation_on_optimize_cooldown, workstation_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_optimize_success, command_radius_limit_exceed, command_optimize_fail, command_unoptimize_success,
command_specify_radius, command_radius_invalid, command_no_villagers_nearby, command_specify_radius, command_radius_invalid, command_no_villagers_nearby,
trades_restocked, optimize_for_trading, villager_leveling_up; trades_restocked, optimize_for_trading, villager_leveling_up;
@ -67,6 +68,10 @@ public class LanguageCache {
"<gray>You need to wait %time% until you can optimize this villager again."); "<gray>You need to wait %time% until you can optimize this villager again.");
this.workstation_unoptimize_success = getListTranslation("messages.workstation.unoptimize-success", this.workstation_unoptimize_success = getListTranslation("messages.workstation.unoptimize-success",
"<green>Successfully unoptimized %villagertype% villager by removing workstation block %blocktype%."); "<green>Successfully unoptimized %villagertype% villager by removing workstation block %blocktype%.");
// Activity
this.activity_optimize_success = getListTranslation("messages.activity.optimized-near-you",
"<gray>%amount% villagers close to you were automatically optimized due to high activity.");
// Command // Command
this.command_optimize_success = getListTranslation("messages.command.optimize-success", this.command_optimize_success = getListTranslation("messages.command.optimize-success",
"<green>Successfully optimized %amount% villager(s) in a radius of %radius% blocks."); "<green>Successfully optimized %amount% villager(s) in a radius of %radius% blocks.");

View File

@ -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<BlockRegion2D, RegionData> 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<BlockRegion2D, RegionData> 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<Player> 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<Player> 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);
});
}
}

View File

@ -99,13 +99,13 @@ public class OptimizeByNametag extends VillagerOptimizerModule implements Listen
if (!optimizeEvent.callEvent()) return; if (!optimizeEvent.callEvent()) return;
wrapped.setOptimizationType(optimizeEvent.getOptimizationType());
wrapped.saveOptimizeTime();
if (!consume_nametag && player.getGameMode() == GameMode.SURVIVAL) { if (!consume_nametag && player.getGameMode() == GameMode.SURVIVAL) {
player.getInventory().addItem(usedItem.asOne()); player.getInventory().addItem(usedItem.asOne());
} }
wrapped.setOptimizationType(optimizeEvent.getOptimizationType());
wrapped.saveOptimizeTime();
if (notify_player) { if (notify_player) {
VillagerOptimizer.getLang(player.locale()).nametag_optimize_success VillagerOptimizer.getLang(player.locale()).nametag_optimize_success
.forEach(line -> KyoriUtil.sendMessage(player, line)); .forEach(line -> KyoriUtil.sendMessage(player, line));

View File

@ -1,6 +1,8 @@
package me.xginko.villageroptimizer.struct.enums; package me.xginko.villageroptimizer.struct.enums;
public enum OptimizationType { public enum OptimizationType {
CHUNK_LIMIT,
REGIONAL_ACTIVITY,
COMMAND, COMMAND,
NAMETAG, NAMETAG,
WORKSTATION, WORKSTATION,

View File

@ -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<Collection<Entity>> 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<Collection<Entity>> 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 +
"}";
}
}

View File

@ -7,6 +7,7 @@ import org.bukkit.Location;
import org.bukkit.Sound; import org.bukkit.Sound;
import org.bukkit.entity.Villager; import org.bukkit.entity.Villager;
import org.bukkit.entity.memory.MemoryKey; import org.bukkit.entity.memory.MemoryKey;
import org.bukkit.event.entity.VillagerReplenishTradeEvent;
import org.bukkit.inventory.MerchantRecipe; import org.bukkit.inventory.MerchantRecipe;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
@ -41,8 +42,11 @@ public class WrappedVillager extends PDCWrapper {
*/ */
public void restock() { public void restock() {
VillagerOptimizer.scheduling().entitySpecificScheduler(villager).run(() -> { VillagerOptimizer.scheduling().entitySpecificScheduler(villager).run(() -> {
for (MerchantRecipe recipe : villager.getRecipes()) { for (MerchantRecipe merchantRecipe : villager.getRecipes()) {
recipe.setUses(0); VillagerReplenishTradeEvent restockRecipeEvent = new VillagerReplenishTradeEvent(villager, merchantRecipe);
if (restockRecipeEvent.callEvent()) {
restockRecipeEvent.getRecipe().setUses(0);
}
} }
}, null); }, null);
} }