458 lines
20 KiB
Java
458 lines
20 KiB
Java
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<ClaimPoint> 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> 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<CoalNpc> findNpc(long id) throws SQLException {
|
|
return repository.findNpc(id);
|
|
}
|
|
|
|
public Optional<CoalNpc> findByCitizensId(int citizensId) throws SQLException {
|
|
return repository.findByCitizensId(citizensId);
|
|
}
|
|
|
|
public Optional<NpcZone> findZone(String name) throws SQLException {
|
|
return repository.findZone(name);
|
|
}
|
|
|
|
public List<CoalNpc> listNpcs() throws SQLException {
|
|
return repository.listNpcs();
|
|
}
|
|
|
|
public List<NpcZone> listZones() throws SQLException {
|
|
return repository.listZones();
|
|
}
|
|
|
|
public Map<Material, Long> 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<CoalNpc> npcs = repository.listNpcs();
|
|
for (CoalNpc coalNpc : npcs) {
|
|
if (!coalNpc.worker()) {
|
|
continue;
|
|
}
|
|
NPC npc = CitizensAPI.getNPCRegistry().getById(coalNpc.citizensId());
|
|
Optional<NpcZone> 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<Material, Long> 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<Material> 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<Material> 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<ItemStack> 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<CoalNpc> 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<CoalNpc> 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<CoalNpc> 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<CoalNpc> tradersInZone(CoalNpc worker, List<CoalNpc> npcs) {
|
|
List<CoalNpc> 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<Material, Long> inputs, List<Material> crops, boolean replant) {
|
|
private boolean farmBehavior() {
|
|
return behavior.equalsIgnoreCase("FARM");
|
|
}
|
|
}
|
|
}
|