package net.citizensnpcs.trait; import java.time.Duration; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.function.Function; import org.apache.logging.log4j.core.lookup.StrSubstitutor; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.command.CommandSender; import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.permissions.PermissionAttachment; import org.bukkit.plugin.RegisteredServiceProvider; import com.google.common.base.Splitter; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.io.BaseEncoding; import net.citizensnpcs.Settings.Setting; import net.citizensnpcs.api.CitizensAPI; import net.citizensnpcs.api.event.NPCCommandDispatchEvent; import net.citizensnpcs.api.gui.InventoryMenuPage; import net.citizensnpcs.api.gui.InventoryMenuSlot; import net.citizensnpcs.api.gui.Menu; import net.citizensnpcs.api.gui.MenuContext; import net.citizensnpcs.api.npc.NPC; import net.citizensnpcs.api.persistence.DelegatePersistence; import net.citizensnpcs.api.persistence.Persist; import net.citizensnpcs.api.persistence.Persister; import net.citizensnpcs.api.trait.Trait; import net.citizensnpcs.api.trait.TraitName; import net.citizensnpcs.api.util.DataKey; import net.citizensnpcs.api.util.Messaging; import net.citizensnpcs.api.util.Translator; import net.citizensnpcs.util.Messages; import net.citizensnpcs.util.StringHelper; import net.citizensnpcs.util.Util; import net.milkbowl.vault.economy.Economy; @TraitName("commandtrait") public class CommandTrait extends Trait { @Persist(keyType = Integer.class) @DelegatePersistence(NPCCommandPersister.class) private final Map commands = Maps.newHashMap(); @Persist private double cost = -1; @Persist private final Map customErrorMessages = Maps.newEnumMap(CommandTraitError.class); private final Map> executionErrors = Maps.newHashMap(); @Persist private ExecutionMode executionMode = ExecutionMode.LINEAR; @Persist private float experienceCost = -1; @Persist(valueType = Long.class) private final Map globalCooldowns = Maps.newHashMap(); @Persist private boolean hideErrorMessages; @Persist private final List itemRequirements = Lists.newArrayList(); @Persist private boolean persistSequence = false; @Persist(keyType = UUID.class, reify = true, value = "cooldowns") private final Map playerTracking = Maps.newHashMap(); @Persist private final List temporaryPermissions = Lists.newArrayList(); public CommandTrait() { super("commandtrait"); } public int addCommand(NPCCommandBuilder builder) { int id = getNewId(); commands.put(id, builder.build(id)); return id; } private boolean chargeCommandCosts(Player player, Hand hand) { if (cost > 0) { try { RegisteredServiceProvider provider = Bukkit.getServicesManager() .getRegistration(Economy.class); if (provider != null && provider.getProvider() != null) { Economy economy = provider.getProvider(); if (!economy.has(player, cost)) { sendErrorMessage(player, CommandTraitError.MISSING_MONEY, null, cost); return false; } economy.withdrawPlayer(player, cost); } } catch (NoClassDefFoundError e) { Messaging.severe("Unable to find Vault when checking command cost - is it installed?"); } } if (experienceCost > 0) { if (player.getLevel() < experienceCost) { sendErrorMessage(player, CommandTraitError.MISSING_EXPERIENCE, null, experienceCost); return false; } player.setLevel((int) (player.getLevel() - experienceCost)); } if (itemRequirements.size() > 0) { List req = Lists.newArrayList(itemRequirements); Inventory tempInventory = Bukkit.createInventory(null, 54); for (int i = 0; i < player.getInventory().getSize(); i++) { tempInventory.setItem(i, player.getInventory().getItem(i)); } for (ItemStack stack : req) { if (tempInventory.containsAtLeast(stack, stack.getAmount())) { tempInventory.removeItem(stack); } else { sendErrorMessage(player, CommandTraitError.MISSING_ITEM, null, Util.prettyEnum(stack.getType()), stack.getAmount()); return false; } } for (int i = 0; i < player.getInventory().getSize(); i++) { player.getInventory().setItem(i, tempInventory.getItem(i)); } } return true; } public void clearHistory(CommandTraitError which, Player who) { Collection toClear = Lists.newArrayList(); if (who != null) { toClear.add(playerTracking.get(who.getUniqueId())); } else { toClear.addAll(playerTracking.values()); } switch (which) { case MAXIMUM_TIMES_USED: for (PlayerNPCCommand tracked : toClear) { tracked.nUsed.clear(); } break; case ON_COOLDOWN: for (PlayerNPCCommand tracked : toClear) { tracked.lastUsed.clear(); } break; case ON_GLOBAL_COOLDOWN: globalCooldowns.clear(); break; default: return; } } /** * Send a brief description of the current state of the trait to the supplied {@link CommandSender}. */ public void describe(CommandSender sender) { List left = Lists.newArrayList(); List right = Lists.newArrayList(); for (NPCCommand command : commands.values()) { if (command.hand == Hand.LEFT || command.hand == Hand.SHIFT_LEFT || command.hand == Hand.BOTH) { left.add(command); } if (command.hand == Hand.RIGHT || command.hand == Hand.SHIFT_RIGHT || command.hand == Hand.BOTH) { right.add(command); } } String output = ""; if (cost > 0) { output += "Cost: " + StringHelper.wrap(cost); } if (experienceCost > 0) { output += " XP cost: " + StringHelper.wrap(experienceCost); } if (left.size() > 0) { output += Messaging.tr(Messages.COMMAND_LEFT_HAND_HEADER); for (NPCCommand command : left) { output += describe(command); } } if (right.size() > 0) { output += Messaging.tr(Messages.COMMAND_RIGHT_HAND_HEADER); for (NPCCommand command : right) { output += describe(command); } } if (output.isEmpty()) { output = Messaging.tr(Messages.COMMAND_NO_COMMANDS_ADDED); } else { output = executionMode + " " + output; } Messaging.send(sender, output); } private String describe(NPCCommand command) { String output = Messaging.tr(Messages.COMMAND_DESCRIBE_TEMPLATE, command.command, StringHelper.wrap( command.cooldown != 0 ? command.cooldown : Setting.NPC_COMMAND_GLOBAL_COMMAND_COOLDOWN.asSeconds()), command.id); if (command.globalCooldown > 0) { output += "[global " + StringHelper.wrap(command.globalCooldown) + "s]"; } if (command.delay > 0) { output += "[delay " + StringHelper.wrap(command.delay) + "t]"; } if (command.n > 0) { output += "[" + StringHelper.wrap(command.n) + " uses]"; } if (command.op) { output += " -o"; } if (command.player) { output += " -p"; } return output; } public void dispatch(final Player player, Hand handIn) { final Hand hand = player.isSneaking() ? (handIn == CommandTrait.Hand.LEFT ? CommandTrait.Hand.SHIFT_LEFT : CommandTrait.Hand.SHIFT_RIGHT) : handIn; NPCCommandDispatchEvent event = new NPCCommandDispatchEvent(npc, player); Bukkit.getServer().getPluginManager().callEvent(event); if (event.isCancelled()) { return; } Runnable task = new Runnable() { Boolean charged = null; @Override public void run() { List commandList = Lists.newArrayList(Iterables.filter(commands.values(), command -> { return command.hand == hand || command.hand == Hand.BOTH; })); if (executionMode == ExecutionMode.RANDOM) { if (commandList.size() > 0) { runCommand(player, commandList.get(Util.getFastRandom().nextInt(commandList.size()))); } return; } int max = -1; if (executionMode == ExecutionMode.SEQUENTIAL) { Collections.sort(commandList, (o1, o2) -> Integer.compare(o1.id, o2.id)); max = commandList.size() > 0 ? commandList.get(commandList.size() - 1).id : -1; } if (executionMode == ExecutionMode.LINEAR) { executionErrors.put(player.getUniqueId().toString(), EnumSet.noneOf(CommandTraitError.class)); } for (NPCCommand command : commandList) { if (executionMode == ExecutionMode.SEQUENTIAL) { PlayerNPCCommand info = playerTracking.get(player.getUniqueId()); if (info != null && info.lastUsedHand != hand) { info.lastUsedHand = hand; info.lastUsedId = -1; } if (info != null && command.id <= info.lastUsedId) { if (info.lastUsedId == max) { info.lastUsedId = -1; } else { continue; } } } runCommand(player, command); if (executionMode == ExecutionMode.SEQUENTIAL || (charged != null && charged == false)) { break; } } } private void runCommand(final Player player, NPCCommand command) { Runnable runnable = () -> { PlayerNPCCommand info = playerTracking.get(player.getUniqueId()); if (info == null && (executionMode == ExecutionMode.SEQUENTIAL || PlayerNPCCommand.requiresTracking(command))) { playerTracking.put(player.getUniqueId(), info = new PlayerNPCCommand()); } if (info != null && !info.canUse(CommandTrait.this, player, command)) { return; } if (charged == null) { if (!chargeCommandCosts(player, hand)) { charged = false; return; } charged = true; } PermissionAttachment attachment = player.addAttachment(CitizensAPI.getPlugin()); if (temporaryPermissions.size() > 0) { for (String permission : temporaryPermissions) { attachment.setPermission(permission, true); } } command.run(npc, player); attachment.remove(); }; if (command.delay <= 0) { runnable.run(); } else { Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), runnable, command.delay); } } }; if (Bukkit.isPrimaryThread()) { task.run(); } else { Bukkit.getScheduler().scheduleSyncDelayedTask(CitizensAPI.getPlugin(), task); } } public String fillPlaceholder(CommandSender sender, String input) { return null; } public double getCost() { return cost; } public ExecutionMode getExecutionMode() { return executionMode; } public float getExperienceCost() { return experienceCost; } private int getNewId() { int i = 0; while (commands.containsKey(i)) { i++; } return i; } public boolean hasCommandId(int id) { return commands.containsKey(id); } public boolean isHideErrorMessages() { return hideErrorMessages; } public boolean persistSequence() { return persistSequence; } public void removeCommandById(int id) { commands.remove(id); } @Override public void save(DataKey key) { Collection commands = this.commands.values(); for (Iterator itr = playerTracking.values().iterator(); itr.hasNext();) { PlayerNPCCommand playerCommand = itr.next(); playerCommand.prune(globalCooldowns, commands); if (playerCommand.lastUsed.isEmpty() && playerCommand.nUsed.isEmpty() && (!persistSequence || playerCommand.lastUsedId == -1)) { itr.remove(); } } } private void sendErrorMessage(Player player, CommandTraitError msg, Function transform, Object... objects) { if (hideErrorMessages) { return; } Set sent = executionErrors.get(player.getUniqueId().toString()); if (sent != null) { if (sent.contains(msg)) return; sent.add(msg); } String messageRaw = customErrorMessages.getOrDefault(msg, msg.setting.asString()); if (transform != null) { messageRaw = transform.apply(messageRaw); } if (messageRaw != null && messageRaw.trim().length() > 0) { Messaging.send(player, Translator.format(messageRaw, objects)); } } public void setCost(double cost) { this.cost = cost; } public void setCustomErrorMessage(CommandTraitError which, String message) { customErrorMessages.put(which, message); } public void setExecutionMode(ExecutionMode mode) { this.executionMode = mode; } public void setExperienceCost(float experienceCost) { this.experienceCost = experienceCost; } public void setHideErrorMessages(boolean hide) { this.hideErrorMessages = hide; } public void setPersistSequence(boolean persistSequence) { this.persistSequence = persistSequence; } public void setTemporaryPermissions(List permissions) { temporaryPermissions.clear(); temporaryPermissions.addAll(permissions); } public enum CommandTraitError { MAXIMUM_TIMES_USED(Setting.NPC_COMMAND_MAXIMUM_TIMES_USED_MESSAGE), MISSING_EXPERIENCE(Setting.NPC_COMMAND_NOT_ENOUGH_EXPERIENCE_MESSAGE), MISSING_ITEM(Setting.NPC_COMMAND_MISSING_ITEM_MESSAGE), MISSING_MONEY(Setting.NPC_COMMAND_NOT_ENOUGH_MONEY_MESSAGE), NO_PERMISSION(Setting.NPC_COMMAND_NO_PERMISSION_MESSAGE), ON_COOLDOWN(Setting.NPC_COMMAND_ON_COOLDOWN_MESSAGE), ON_GLOBAL_COOLDOWN(Setting.NPC_COMMAND_ON_GLOBAL_COOLDOWN_MESSAGE); private final Setting setting; CommandTraitError(Setting setting) { this.setting = setting; } } public enum ExecutionMode { LINEAR, RANDOM, SEQUENTIAL; @Override public String toString() { return name().charAt(0) + name().substring(1).toLowerCase(); } } public static enum Hand { BOTH, LEFT, RIGHT, SHIFT_LEFT, SHIFT_RIGHT; } @Menu(title = "Drag items for requirements", type = InventoryType.CHEST, dimensions = { 5, 9 }) public static class ItemRequirementGUI extends InventoryMenuPage { private Inventory inventory; private CommandTrait trait; private ItemRequirementGUI() { throw new UnsupportedOperationException(); } public ItemRequirementGUI(CommandTrait trait) { this.trait = trait; } @Override public void initialise(MenuContext ctx) { this.inventory = ctx.getInventory(); for (ItemStack stack : trait.itemRequirements) { inventory.addItem(stack.clone()); } } @Override public void onClick(InventoryMenuSlot slot, InventoryClickEvent event) { event.setCancelled(false); } @Override public void onClose(HumanEntity player) { List requirements = Lists.newArrayList(); for (ItemStack stack : inventory.getContents()) { if (stack != null && stack.getType() != Material.AIR) { requirements.add(stack); } } this.trait.itemRequirements.clear(); this.trait.itemRequirements.addAll(requirements); } } private static class NPCCommand { String bungeeServer; String command; int cooldown; int delay; int globalCooldown; Hand hand; int id; String key; int n; boolean op; List perms; boolean player; public NPCCommand(int id, String command, Hand hand, boolean player, boolean op, int cooldown, List perms, int n, int delay, int globalCooldown) { this.id = id; this.command = command; this.hand = hand; this.player = player; this.op = op; this.cooldown = cooldown; this.perms = perms; this.n = n; this.delay = delay; this.globalCooldown = globalCooldown; List split = Splitter.on(' ').omitEmptyStrings().trimResults().limit(2).splitToList(command); this.bungeeServer = split.size() == 2 && split.get(0).equalsIgnoreCase("server") ? split.get(1) : null; } public String getEncodedKey() { if (key != null) return key; return key = BaseEncoding.base64().encode(command.getBytes()); } public void run(NPC npc, Player clicker) { Util.runCommand(npc, clicker, command, op, player); } } public static class NPCCommandBuilder { String command; int cooldown; int delay; int globalCooldown; Hand hand; int n = -1; boolean op; List perms = Lists.newArrayList(); boolean player; public NPCCommandBuilder(String command, Hand hand) { this.command = command; this.hand = hand; } public NPCCommandBuilder addPerm(String permission) { this.perms.add(permission); return this; } public NPCCommandBuilder addPerms(List perms) { this.perms.addAll(perms); return this; } private NPCCommand build(int id) { return new NPCCommand(id, command, hand, player, op, cooldown, perms, n, delay, globalCooldown); } public NPCCommandBuilder command(String command) { this.command = command; return this; } public NPCCommandBuilder cooldown(Duration cd) { return cooldown((int) TimeUnit.SECONDS.convert(cd)); } public NPCCommandBuilder cooldown(int cooldown) { this.cooldown = cooldown; return this; } public NPCCommandBuilder delay(int delay) { this.delay = delay; return this; } public NPCCommandBuilder globalCooldown(Duration cd) { return globalCooldown((int) TimeUnit.SECONDS.convert(cd)); } public NPCCommandBuilder globalCooldown(int cooldown) { this.globalCooldown = cooldown; return this; } public NPCCommandBuilder n(int n) { this.n = n; return this; } public NPCCommandBuilder op(boolean op) { this.op = op; return this; } public NPCCommandBuilder player(boolean player) { this.player = player; return this; } } private static class NPCCommandPersister implements Persister { public NPCCommandPersister() { } @Override public NPCCommand create(DataKey root) { List perms = Lists.newArrayList(); for (DataKey key : root.getRelative("permissions").getIntegerSubKeys()) { perms.add(key.getString("")); } return new NPCCommand(Integer.parseInt(root.name()), root.getString("command"), Hand.valueOf(root.getString("hand")), Boolean.valueOf(root.getString("player")), Boolean.valueOf(root.getString("op")), root.getInt("cooldown"), perms, root.getInt("n"), root.getInt("delay"), root.getInt("globalcooldown")); } @Override public void save(NPCCommand instance, DataKey root) { root.setString("command", instance.command); root.setString("hand", instance.hand.name()); root.setBoolean("player", instance.player); root.setBoolean("op", instance.op); root.setInt("cooldown", instance.cooldown); root.setInt("globalcooldown", instance.globalCooldown); root.setInt("n", instance.n); root.setInt("delay", instance.delay); for (int i = 0; i < instance.perms.size(); i++) { root.setString("permissions." + i, instance.perms.get(i)); } } } private static class PlayerNPCCommand { @Persist(valueType = Long.class) Map lastUsed = Maps.newHashMap(); @Persist Hand lastUsedHand; @Persist int lastUsedId = -1; @Persist Map nUsed = Maps.newHashMap(); public PlayerNPCCommand() { } public boolean canUse(CommandTrait trait, Player player, NPCCommand command) { for (String perm : command.perms) { if (!player.hasPermission(perm)) { trait.sendErrorMessage(player, CommandTraitError.NO_PERMISSION, null); return false; } } long globalDelay = Setting.NPC_COMMAND_GLOBAL_COMMAND_COOLDOWN.asSeconds(); long currentTimeSec = System.currentTimeMillis() / 1000; String commandKey = command.getEncodedKey(); if (lastUsed.containsKey(commandKey)) { long deadline = ((Number) lastUsed.get(commandKey)).longValue() + (command.cooldown != 0 ? command.cooldown : globalDelay); if (currentTimeSec < deadline) { long seconds = deadline - currentTimeSec; trait.sendErrorMessage(player, CommandTraitError.ON_COOLDOWN, new TimeVariableFormatter(seconds, TimeUnit.SECONDS), seconds); return false; } lastUsed.remove(commandKey); } if (command.globalCooldown > 0 && trait.globalCooldowns.containsKey(commandKey)) { long deadline = ((Number) trait.globalCooldowns.get(commandKey)).longValue() + command.globalCooldown; if (currentTimeSec < deadline) { long seconds = deadline - currentTimeSec; trait.sendErrorMessage(player, CommandTraitError.ON_GLOBAL_COOLDOWN, new TimeVariableFormatter(seconds, TimeUnit.SECONDS), seconds); return false; } trait.globalCooldowns.remove(commandKey); } int timesUsed = nUsed.getOrDefault(commandKey, 0); if (command.n > 0 && command.n <= timesUsed) { trait.sendErrorMessage(player, CommandTraitError.MAXIMUM_TIMES_USED, null, command.n); return false; } if (command.cooldown > 0 || globalDelay > 0) { lastUsed.put(commandKey, currentTimeSec); } if (command.globalCooldown > 0) { trait.globalCooldowns.put(commandKey, currentTimeSec); } if (command.n > 0) { nUsed.put(commandKey, timesUsed + 1); } lastUsedId = command.id; return true; } public void prune(Map globalCooldowns, Collection commands) { long currentTimeSec = System.currentTimeMillis() / 1000; Set commandKeys = Sets.newHashSet(); for (NPCCommand command : commands) { String commandKey = command.getEncodedKey(); commandKeys.add(commandKey); Number number = lastUsed.get(commandKey); if (number != null && number.longValue() + (command.cooldown != 0 ? command.cooldown : Setting.NPC_COMMAND_GLOBAL_COMMAND_COOLDOWN.asSeconds()) <= currentTimeSec) { lastUsed.remove(commandKey); } if (globalCooldowns != null) { number = globalCooldowns.get(commandKey); if (number != null && number.longValue() + command.globalCooldown <= currentTimeSec) { globalCooldowns.remove(commandKey); } } } Set diff = Sets.newHashSet(lastUsed.keySet()); diff.removeAll(commandKeys); for (String key : diff) { lastUsed.remove(key); nUsed.remove(key); } if (globalCooldowns != null) { diff = Sets.newHashSet(globalCooldowns.keySet()); diff.removeAll(commandKeys); for (String key : diff) { globalCooldowns.remove(key); } } } public static boolean requiresTracking(NPCCommand command) { return command.globalCooldown > 0 || command.cooldown > 0 || command.n > 0 || (command.perms != null && command.perms.size() > 0) || Setting.NPC_COMMAND_GLOBAL_COMMAND_COOLDOWN.asSeconds() > 0; } } private static class TimeVariableFormatter implements Function { private final Map map = Maps.newHashMapWithExpectedSize(5); public TimeVariableFormatter(long source, TimeUnit unit) { long seconds = TimeUnit.SECONDS.convert(source, unit); long minutes = TimeUnit.MINUTES.convert(source, unit); long hours = TimeUnit.HOURS.convert(source, unit); long days = TimeUnit.DAYS.convert(source, unit); map.put("seconds", "" + seconds); map.put("seconds_over", "" + (seconds - TimeUnit.SECONDS.convert(minutes, TimeUnit.MINUTES))); map.put("minutes", "" + minutes); map.put("minutes_over", "" + (minutes - TimeUnit.MINUTES.convert(hours, TimeUnit.HOURS))); map.put("hours", "" + hours); map.put("hours_over", "" + (hours - TimeUnit.HOURS.convert(days, TimeUnit.DAYS))); map.put("days", "" + days); } @Override public String apply(String t) { return StrSubstitutor.replace(t, map, "{", "}"); } } }