package com.librewiki.coalgov.service; import com.librewiki.coalgov.CoalGovPlugin; import com.librewiki.coalgov.model.ClaimPoint; import com.librewiki.coalgov.model.CoalNpc; import com.librewiki.coalgov.model.NpcZone; import com.librewiki.coalgov.storage.NpcRepository; import com.librewiki.coalgov.util.CoalMoney; import net.citizensnpcs.api.CitizensAPI; import net.citizensnpcs.api.npc.NPC; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.Ageable; import org.bukkit.block.data.BlockData; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Random; import java.util.UUID; public final class NpcService { private final CoalGovPlugin plugin; private final NpcRepository repository; private final Random random = new Random(); public NpcService(CoalGovPlugin plugin, NpcRepository repository) { this.plugin = plugin; this.repository = repository; } public long createZone(String name, String world, List points) throws SQLException { return repository.createZone(name, world, points); } public long createNpc(String type, String name, NpcZone zone, Location location) throws SQLException { NPC npc = CitizensAPI.getNPCRegistry().createNPC(EntityType.VILLAGER, name); npc.spawn(location); npc.setProtected(true); UUID accountUuid = UUID.nameUUIDFromBytes(("coalgov:npc:" + npc.getId()).getBytes(StandardCharsets.UTF_8)); plugin.economyService().ensureAccount(accountUuid, "NPC-" + name); return repository.createNpc(npc.getId(), accountUuid, type, name, zone.id(), location.getWorld().getName(), location.getX(), location.getY(), location.getZ()); } public boolean removeNpc(long id) throws SQLException { Optional coalNpc = repository.findNpc(id); if (coalNpc.isPresent()) { NPC npc = CitizensAPI.getNPCRegistry().getById(coalNpc.get().citizensId()); if (npc != null) { npc.destroy(); } } return repository.deleteNpc(id); } public Optional findNpc(long id) throws SQLException { return repository.findNpc(id); } public Optional findByCitizensId(int citizensId) throws SQLException { return repository.findByCitizensId(citizensId); } public Optional findZone(String name) throws SQLException { return repository.findZone(name); } public List listNpcs() throws SQLException { return repository.listNpcs(); } public List listZones() throws SQLException { return repository.listZones(); } public Map inventory(long npcId) throws SQLException { return repository.inventory(npcId); } public void addInventory(long npcId, Material material, long amount) throws SQLException { repository.addInventory(npcId, material, amount); } public long balance(CoalNpc npc) throws SQLException { return plugin.economyService().balance(npc.accountUuid()); } public HaggleQuote quote(CoalNpc npc, String mode, Material material, int amount, long offer) throws SQLException { double base = plugin.marketService().basePrices().getOrDefault(material, 1.0D); long fair = Math.max(1L, CoalMoney.fromCoal(base * amount)); int maxDiscount = plugin.getConfig().getInt("npc.trader.max_discount_percent", 15); int maxSurcharge = plugin.getConfig().getInt("npc.trader.max_surcharge_percent", 20); if (mode.equalsIgnoreCase("buy")) { long stock = repository.inventoryAmount(npc.id(), material); long listPrice = Math.max(1L, Math.round(fair * 1.10D)); long minimum = Math.max(1L, Math.round(listPrice * (100.0D - maxDiscount) / 100.0D)); long counter = Math.max(minimum, Math.min(listPrice, offer <= 0L ? listPrice : Math.round((offer + listPrice) / 2.0D))); boolean possible = stock >= amount; boolean accepted = possible && offer >= minimum; return new HaggleQuote(mode.toLowerCase(), material, amount, offer, minimum, listPrice, counter, accepted, possible ? "" : "I do not have enough stock."); } long maximum = Math.max(1L, Math.round(fair * (100.0D + maxSurcharge) / 100.0D)); long target = Math.max(1L, Math.round(fair * 0.90D)); long counter = Math.min(maximum, Math.max(target, offer <= 0L ? target : Math.round((offer + target) / 2.0D))); boolean possible = balance(npc) >= offer; boolean accepted = possible && offer <= maximum; return new HaggleQuote(mode.toLowerCase(), material, amount, offer, target, maximum, counter, accepted, possible ? "" : "I do not have enough coal."); } public boolean completeTrade(Player player, CoalNpc npc, HaggleQuote quote, long finalPrice) throws SQLException { if (quote.mode().equals("buy")) { if (repository.inventoryAmount(npc.id(), quote.material()) < quote.amount()) { return false; } if (!plugin.economyService().charge(player.getUniqueId(), finalPrice, "npc_purchase")) { return false; } repository.removeInventory(npc.id(), quote.material(), quote.amount()); plugin.economyService().credit(npc.accountUuid(), finalPrice, "npc_sale"); player.getInventory().addItem(new ItemStack(quote.material(), quote.amount())); return true; } if (!playerHas(player, quote.material(), quote.amount()) || balance(npc) < finalPrice) { return false; } if (!plugin.economyService().charge(npc.accountUuid(), finalPrice, "npc_purchase")) { return false; } removePlayerItems(player, quote.material(), quote.amount()); plugin.economyService().credit(player.getUniqueId(), finalPrice, "npc_sale"); repository.addInventory(npc.id(), quote.material(), quote.amount()); return true; } public void tickWorkers() { try { List npcs = repository.listNpcs(); for (CoalNpc coalNpc : npcs) { if (!coalNpc.worker()) { continue; } NPC npc = CitizensAPI.getNPCRegistry().getById(coalNpc.citizensId()); Optional zone = repository.findZone(coalNpc.zoneId()); if (npc == null || zone.isEmpty()) { continue; } if (!npc.isSpawned()) { World world = Bukkit.getWorld(coalNpc.world()); if (world != null) { npc.spawn(new Location(world, coalNpc.x(), coalNpc.y(), coalNpc.z())); } } if (npc.isSpawned() && !npc.getNavigator().isNavigating()) { Location target = randomPoint(zone.get()); if (target != null) { npc.getNavigator().setTarget(target); } } WorkerRecipe recipe = recipeFor(coalNpc); if (recipe == null) { continue; } if (recipe.farmBehavior()) { harvestCrop(coalNpc, recipe, zone.get(), npc); supplyTraders(coalNpc, recipe, npcs); continue; } if (produce(coalNpc, recipe, npcs)) { supplyTraders(coalNpc, recipe, npcs); } } } catch (SQLException exception) { plugin.getLogger().warning("NPC worker tick failed: " + exception.getMessage()); } } private WorkerRecipe recipeFor(CoalNpc npc) { ConfigurationSection roles = plugin.getConfig().getConfigurationSection("npc.worker.roles"); if (roles != null) { String npcName = npc.name().toLowerCase(); for (String role : roles.getKeys(false)) { if (npcName.contains(role.toLowerCase())) { WorkerRecipe recipe = recipe(role, roles.getConfigurationSection(role)); if (recipe != null) { return recipe; } } } } if (!plugin.getConfig().getBoolean("npc.worker.allow_default_production", false)) { return null; } Material material = Material.matchMaterial(plugin.getConfig().getString("npc.worker.produce_material", "BREAD")); int amount = plugin.getConfig().getInt("npc.worker.produce_amount", 2); return material == null || amount <= 0 ? null : new WorkerRecipe("default", "PRODUCE", material, amount, Map.of(), List.of(), true); } private WorkerRecipe recipe(String name, ConfigurationSection section) { if (section == null) { return null; } Material output = Material.matchMaterial(section.getString("output_material", "")); int outputAmount = section.getInt("output_amount", 0); if (output == null || outputAmount <= 0) { return null; } Map inputs = new LinkedHashMap<>(); ConfigurationSection inputSection = section.getConfigurationSection("inputs"); if (inputSection != null) { for (String key : inputSection.getKeys(false)) { Material material = Material.matchMaterial(key); long amount = inputSection.getLong(key); if (material != null && amount > 0L) { inputs.put(material, amount); } } } List crops = new ArrayList<>(); for (String raw : section.getStringList("crops")) { Material crop = Material.matchMaterial(raw); if (crop != null) { crops.add(crop); } } String behavior = section.getString("behavior", inputs.isEmpty() ? "PRODUCE" : "CRAFT"); boolean replant = section.getBoolean("replant", true); return new WorkerRecipe(name, behavior.toUpperCase(), output, outputAmount, inputs, crops, replant); } private boolean harvestCrop(CoalNpc worker, WorkerRecipe recipe, NpcZone zone, NPC npc) throws SQLException { if (!npc.isSpawned()) { return false; } Block crop = findMatureCrop(zone, recipe); if (crop == null) { return false; } Location cropLocation = crop.getLocation().add(0.5D, 0.0D, 0.5D); double actionDistance = Math.max(1.0D, plugin.getConfig().getDouble("npc.worker.farm.action_distance", 3.0D)); if (npc.getEntity().getLocation().distanceSquared(cropLocation) > actionDistance * actionDistance) { npc.getNavigator().setTarget(cropLocation); return false; } collectDrops(worker, crop.getDrops()); Material cropType = crop.getType(); crop.setType(Material.AIR); if (recipe.replant()) { replant(worker, crop, cropType); } return true; } private Block findMatureCrop(NpcZone zone, WorkerRecipe recipe) { World world = Bukkit.getWorld(zone.world()); if (world == null) { return null; } List crops = recipe.crops().isEmpty() ? List.of(Material.WHEAT, Material.CARROTS, Material.POTATOES, Material.BEETROOTS) : recipe.crops(); int maxScans = Math.max(16, plugin.getConfig().getInt("npc.worker.farm.max_scan_blocks", 512)); for (int scan = 0; scan < maxScans; scan++) { int x = zone.x1() + random.nextInt(Math.max(1, zone.x2() - zone.x1() + 1)); int z = zone.z1() + random.nextInt(Math.max(1, zone.z2() - zone.z1() + 1)); if (!zone.contains(world.getName(), x, z)) { continue; } int topY = world.getHighestBlockYAt(x, z) + 1; for (int y = topY; y >= Math.max(world.getMinHeight(), topY - 4); y--) { Block block = world.getBlockAt(x, y, z); if (crops.contains(block.getType()) && mature(block)) { return block; } } } return null; } private boolean mature(Block block) { BlockData data = block.getBlockData(); return data instanceof Ageable ageable && ageable.getAge() >= ageable.getMaximumAge(); } private void collectDrops(CoalNpc worker, Collection drops) throws SQLException { for (ItemStack drop : drops) { if (drop == null || drop.getType().isAir() || drop.getAmount() <= 0) { continue; } repository.addInventory(worker.id(), drop.getType(), drop.getAmount()); } } private boolean replant(CoalNpc worker, Block block, Material cropType) throws SQLException { Material seed = plantingMaterial(cropType); if (seed == null || repository.inventoryAmount(worker.id(), seed) <= 0L) { return false; } if (!repository.removeInventory(worker.id(), seed, 1L)) { return false; } block.setType(cropType); BlockData data = block.getBlockData(); if (data instanceof Ageable ageable) { ageable.setAge(0); block.setBlockData(ageable); } return true; } private Material plantingMaterial(Material cropType) { return switch (cropType) { case WHEAT -> Material.WHEAT_SEEDS; case CARROTS -> Material.CARROT; case POTATOES -> Material.POTATO; case BEETROOTS -> Material.BEETROOT_SEEDS; default -> null; }; } private boolean produce(CoalNpc worker, WorkerRecipe recipe, List npcs) throws SQLException { for (var input : recipe.inputs().entrySet()) { long stocked = repository.inventoryAmount(worker.id(), input.getKey()); long missing = input.getValue() - stocked; if (missing > 0L) { requestInput(worker, input.getKey(), missing, npcs); } if (repository.inventoryAmount(worker.id(), input.getKey()) < input.getValue()) { return false; } } for (var input : recipe.inputs().entrySet()) { if (!repository.removeInventory(worker.id(), input.getKey(), input.getValue())) { return false; } } repository.addInventory(worker.id(), recipe.output(), recipe.outputAmount()); return true; } private void requestInput(CoalNpc requester, Material material, long missing, List npcs) throws SQLException { long remaining = missing; long reserve = Math.max(0L, plugin.getConfig().getLong("npc.supply_chain.source_reserve", 16L)); long maxTransfer = Math.max(1L, plugin.getConfig().getLong("npc.supply_chain.max_transfer_per_tick", 64L)); for (CoalNpc source : npcs) { if (remaining <= 0L) { return; } if (source.id() == requester.id() || source.zoneId() != requester.zoneId()) { continue; } long available = Math.max(0L, repository.inventoryAmount(source.id(), material) - reserve); long moved = Math.min(Math.min(available, remaining), maxTransfer); if (moved <= 0L || !repository.removeInventory(source.id(), material, moved)) { continue; } repository.addInventory(requester.id(), material, moved); remaining -= moved; } } private void supplyTraders(CoalNpc worker, WorkerRecipe recipe, List npcs) throws SQLException { if (!plugin.getConfig().getBoolean("npc.supply_chain.traders_accept_outputs", true)) { return; } long reserve = Math.max(0L, plugin.getConfig().getLong("npc.supply_chain.worker_output_reserve", 16L)); long target = Math.max(0L, plugin.getConfig().getLong("npc.supply_chain.trader_restock_target", 256L)); long maxTransfer = Math.max(1L, plugin.getConfig().getLong("npc.supply_chain.max_transfer_per_tick", 64L)); long available = Math.max(0L, repository.inventoryAmount(worker.id(), recipe.output()) - reserve); if (available <= 0L) { return; } for (CoalNpc trader : tradersInZone(worker, npcs)) { long needed = target - repository.inventoryAmount(trader.id(), recipe.output()); long moved = Math.min(Math.min(needed, available), maxTransfer); if (moved <= 0L || !repository.removeInventory(worker.id(), recipe.output(), moved)) { continue; } repository.addInventory(trader.id(), recipe.output(), moved); available -= moved; if (available <= 0L) { return; } } } private List tradersInZone(CoalNpc worker, List npcs) { List traders = new ArrayList<>(); for (CoalNpc npc : npcs) { if (npc.trader() && npc.zoneId() == worker.zoneId()) { traders.add(npc); } } return traders; } private Location randomPoint(NpcZone zone) { World world = Bukkit.getWorld(zone.world()); if (world == null) { return null; } for (int tries = 0; tries < 32; tries++) { int x = zone.x1() + random.nextInt(Math.max(1, zone.x2() - zone.x1() + 1)); int z = zone.z1() + random.nextInt(Math.max(1, zone.z2() - zone.z1() + 1)); if (zone.contains(world.getName(), x, z)) { return new Location(world, x + 0.5D, world.getHighestBlockYAt(x, z), z + 0.5D); } } return new Location(world, zone.x1() + 0.5D, world.getHighestBlockYAt(zone.x1(), zone.z1()), zone.z1() + 0.5D); } private boolean playerHas(Player player, Material material, int amount) { int found = 0; for (ItemStack item : player.getInventory().getStorageContents()) { if (item != null && item.getType() == material) { found += item.getAmount(); } } return found >= amount; } private void removePlayerItems(Player player, Material material, int amount) { int remaining = amount; for (ItemStack item : player.getInventory().getStorageContents()) { if (item == null || item.getType() != material) { continue; } int take = Math.min(remaining, item.getAmount()); item.setAmount(item.getAmount() - take); remaining -= take; if (remaining <= 0) { return; } } } public record HaggleQuote(String mode, Material material, int amount, long offer, long floor, long ceiling, long counter, boolean accepted, String reason) { } private record WorkerRecipe(String name, String behavior, Material output, int outputAmount, Map inputs, List crops, boolean replant) { private boolean farmBehavior() { return behavior.equalsIgnoreCase("FARM"); } } }