Files
Coalgov/CoalGov/src/main/java/com/librewiki/coalgov/service/NpcService.java
2026-05-06 03:23:18 +00:00

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");
}
}
}