/** * BetonQuest - advanced quests for Bukkit * Copyright (C) 2016 Jakub "Co0sh" Sapalski * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package pl.betoncraft.betonquest.conversation; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.entity.Player; 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.EntityDamageByEntityEvent; import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.event.player.PlayerCommandPreprocessEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.scheduler.BukkitRunnable; import pl.betoncraft.betonquest.BetonQuest; import pl.betoncraft.betonquest.ConditionID; import pl.betoncraft.betonquest.EventID; import pl.betoncraft.betonquest.api.ConversationOptionEvent; import pl.betoncraft.betonquest.api.PlayerConversationEndEvent; import pl.betoncraft.betonquest.api.PlayerConversationStartEvent; import pl.betoncraft.betonquest.config.Config; import pl.betoncraft.betonquest.config.ConfigPackage; import pl.betoncraft.betonquest.conversation.ConversationData.OptionType; import pl.betoncraft.betonquest.database.Connector.UpdateType; import pl.betoncraft.betonquest.database.Saver.Record; import pl.betoncraft.betonquest.utils.Debug; import pl.betoncraft.betonquest.utils.PlayerConverter; /** * Represents a conversation between player and NPC * * @author Jakub Sapalski */ public class Conversation implements Listener { private static ConcurrentHashMap<String, Conversation> list = new ConcurrentHashMap<>(); private final String playerID; private final Player player; private final ConfigPackage pack; private final String language; private ConversationData data; private final Location location; private final String convID; private final List<String> blacklist; private ConversationIO inOut; private String option; private final Conversation conv; private final BetonQuest plugin; private boolean ended = false; private boolean messagesDelaying = false; private ArrayList<String> messages = new ArrayList<>(); private HashMap<Integer, String> current = new HashMap<>(); /** * Starts a new conversation between player and npc at given location. It uses * starting options to determine where to start. * * @param playerID * ID of the player * @param conversationID * ID of the conversation * @param location * location where the conversation has been started */ public Conversation(String playerID, String conversationID, Location location) { this(playerID, conversationID, location, null); } /** * Starts a new conversation between player and npc at given location, * starting with the given option. If the option is null, then it will start * from the beginning. * * @param playerID * ID of the player * @param conversationID * ID of the conversation * @param location * location where the conversation has been started * @param option * ID of the option from where to start */ public Conversation(final String playerID, final String conversationID, final Location location, String option) { this.conv = this; this.plugin = BetonQuest.getInstance(); this.playerID = playerID; this.player = PlayerConverter.getPlayer(playerID); this.pack = Config.getPackages().get(conversationID.substring(0, conversationID.indexOf('.'))); this.language = plugin.getPlayerData(playerID).getLanguage(); this.location = location; this.convID = conversationID; this.data = plugin.getConversation(convID); this.blacklist = plugin.getConfig().getStringList("cmd_blacklist"); this.messagesDelaying = plugin.getConfig().getString("display_chat_after_conversation").equalsIgnoreCase("true"); // check if data is present if (data == null) { Debug.error("Conversation doesn't exist: " + conversationID); return; } // if the player has active conversation, terminate this one if (list.containsKey(playerID)) { Debug.info("Player " + PlayerConverter.getName(playerID) + " is in conversation right now, returning."); return; } // add the player to the list of active conversations list.put(playerID, conv); String[] options; if (option == null) { options = null; } else { if (!option.contains(".")) option = conversationID.substring(conversationID.indexOf('.') + 1) + "." + option; options = new String[] { option }; } new Starter(options).runTaskAsynchronously(plugin); } /** * Chooses the first available option. * * @param options * list of option pointers separated by commas * @param force * setting it to true will force the first option, even if * conditions are not met */ private void selectOption(String[] options, boolean force) { if (force) { options = new String[] { options[0] }; } // get npc's text option = null; options: for (String option : options) { String convName, optionName; if (option.contains(".")) { String[] parts = option.split("\\."); convName = parts[0]; optionName = parts[1]; } else { convName = data.getName(); optionName = option; } ConversationData currentData = plugin.getConversation(pack.getName() + "." + convName); if (!force) for (ConditionID condition : currentData.getConditionIDs(optionName, OptionType.NPC)) { if (!BetonQuest.condition(this.playerID, condition)) { continue options; } } this.option = optionName; data = currentData; break; } } /** * Sends to the player the text said by NPC. It uses the selected option and * displays it. Note: this method now requires a prior call to * selectOption() */ private void printNPCText() { // if there are no possible options, end conversation if (option == null) { new ConversationEnder().runTask(plugin); return; } String text = data.getText(language, option, OptionType.NPC); // resolve variables for (String variable : BetonQuest.resolveVariables(text)) { text = text.replace(variable, plugin.getVariableValue(data.getPackName(), variable, playerID)); } // print option to the player inOut.setNpcResponse(data.getQuester(language), text); new NPCEventRunner(option).runTask(plugin); } /** * Passes given string as answer from player in a conversation. * * @param number * the message player has sent on chat */ public void passPlayerAnswer(int number) { inOut.clear(); new PlayerEventRunner(current.get(number)).runTask(plugin); // clear hashmap current.clear(); } /** * Prints answers the player can choose. * * @param options * list of pointers to player options separated by commas */ private void printOptions(String[] options) { // i is for counting replies, like 1. something, 2. something else int i = 0; answers: for (String option : options) { for (ConditionID condition : data.getConditionIDs(option, OptionType.PLAYER)) { if (!BetonQuest.condition(playerID, condition)) { continue answers; } } i++; // print reply and put it to the hashmap current.put(Integer.valueOf(i), option); // replace variables with their values String text = data.getText(language, option, OptionType.PLAYER); for (String variable : BetonQuest.resolveVariables(text)) { text = text.replace(variable, plugin.getVariableValue(data.getPackName(), variable, playerID)); } inOut.addPlayerOption(text); } new BukkitRunnable() { @Override public void run() { inOut.display(); } }.runTask(plugin); // end conversations if there are no possible options if (current.isEmpty()) { new ConversationEnder().runTask(plugin); return; } } /** * Ends conversation, firing final events and removing it from the list of * active conversations */ public void endConversation() { if (ended) return; ended = true; inOut.end(); // fire final events for (EventID event : data.getFinalEvents()) { BetonQuest.event(playerID, event); } // print message Config.sendMessage(playerID, "conversation_end", new String[] { data.getQuester(language) }, "end"); // delete conversation list.remove(playerID); HandlerList.unregisterAll(this); displayStoredMessages(); Bukkit.getServer().getPluginManager().callEvent(new PlayerConversationEndEvent(player, this)); } private void displayStoredMessages() { for (String message : messages) { player.sendMessage(message); } } /** * Checks if the movement of the player should be blocked. * * @return true if the movement should be blocked, false otherwise */ public boolean isMovementBlock() { return data.isMovementBlocked(); } @EventHandler public void onCommand(PlayerCommandPreprocessEvent event) { if (!event.getPlayer().equals(player)) { return; } if (event.getMessage() == null) return; String cmdName = event.getMessage().split(" ")[0].substring(1); if (blacklist.contains(cmdName)) { event.setCancelled(true); Config.sendMessage(PlayerConverter.getID(event.getPlayer()), "command_blocked"); } } @EventHandler public void onDamage(EntityDamageByEntityEvent event) { // prevent damage to (or from) player while in conversation if ((event.getEntity() instanceof Player && PlayerConverter.getID((Player) event.getEntity()).equals(playerID)) || (event.getDamager() instanceof Player && PlayerConverter.getID((Player) event.getDamager()).equals(playerID))) { event.setCancelled(true); } } @EventHandler public void onQuit(PlayerQuitEvent event) { // if player quits, end conversation (why keep listeners running?) if (event.getPlayer().equals(player)) { if (isMovementBlock()) { suspend(); } else { endConversation(); } } } @EventHandler(priority=EventPriority.HIGHEST) public void onChat(AsyncPlayerChatEvent event) { // store all messages so they can be displayed to the player // once the conversation is finished if (!messagesDelaying) { return; } if (event.isCancelled()) { return; } if (event.getPlayer() != player && event.getRecipients().contains(player)) { event.getRecipients().remove(player); addMessage(String.format(event.getFormat(), event.getPlayer().getDisplayName(), event.getMessage())); } } /** * This method prevents concurrent list modification */ private synchronized void addMessage(String message) { messages.add(message); } /** * Instead of ending the conversation it saves it to the database, from * where it will be resumed after the player logs in again. */ public void suspend() { if (inOut == null) { Debug.error("Conversation IO is not loaded, conversation will end for player " + PlayerConverter.getName(playerID)); list.remove(playerID); HandlerList.unregisterAll(this); return; } inOut.end(); // save the conversation to the database String loc = location.getX() + ";" + location.getY() + ";" + location.getZ() + ";" + location.getWorld().getName(); plugin.getSaver().add(new Record(UpdateType.UPDATE_CONVERSATION, new String[] { convID + " " + option + " " + loc, playerID })); // delete conversation list.remove(playerID); HandlerList.unregisterAll(this); Bukkit.getServer().getPluginManager().callEvent(new PlayerConversationEndEvent(player, this)); } /** * Checks if the player is in a conversation * * @param playerID * ID of the player * @return if the player is on the list of active conversations */ public static boolean containsPlayer(String playerID) { return list.containsKey(playerID); } /** * Gets this player's active conversation. * * @param playerID * ID of the player * @return player's active conversation or null if there is no conversation */ public static Conversation getConversation(String playerID) { return list.get(playerID); } /** * @return the location where the conversation has been started */ public Location getLocation() { return location; } /** * @return the ConversationIO object used by this conversation */ public ConversationIO getIO() { return inOut; } /** * @return the data of the conversation */ public ConversationData getData() { return data; } /** * @return the package containing this conversation */ public ConfigPackage getPackage() { return pack; } /** * @return the ID of the conversation */ public String getID() { return convID; } /** * Starts the conversation, should be called asynchronously. * * @author Jakub Sapalski */ private class Starter extends BukkitRunnable { private String[] options; public Starter(String[] options) { this.options = options; } public void run() { // the conversation start event must be run on next tick PlayerConversationStartEvent event = new PlayerConversationStartEvent(player, conv); Bukkit.getServer().getPluginManager().callEvent(event); // stop the conversation if it's canceled if (event.isCancelled()) return; // now the conversation should start no matter what; // the inOut can be safely instantiated; doing it before // would leave it active while the conversation is not // started, causing it to display "null" all the time try { String name = data.getConversationIO(); Class<? extends ConversationIO> c = plugin.getConvIO(name); conv.inOut = c.getConstructor(Conversation.class, String.class).newInstance(conv, playerID); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { e.printStackTrace(); Debug.error("Error when loading conversation IO"); return; } // register listener for immunity, blocking commands and storing chat messages Bukkit.getPluginManager().registerEvents(conv, BetonQuest.getInstance()); if (options == null) { options = data.getStartingOptions(); // first select the option before sending message, so it // knows which is used selectOption(options, false); // check whether to add a prefix String prefix = data.getPrefix(language, option); String prefixName = null; String[] prefixVariables = null; if (prefix != null) { prefixName = "conversation_prefix"; prefixVariables = new String[] { prefix }; } // print message about starting a conversation only if it // is started, not resumed Config.sendMessage(playerID, "conversation_start", new String[] { data.getQuester(language) }, "start", prefixName, prefixVariables); } else { // don't forget to select the option prior to printing its text selectOption(options, true); } // print NPC's text printNPCText(); ConversationOptionEvent e = new ConversationOptionEvent(player, conv, option, conv.option); Bukkit.getPluginManager().callEvent(e); } } /** * Fires events from the option. Should be called in the main thread. * * @author Jakub Sapalski */ private class NPCEventRunner extends BukkitRunnable { private String option; public NPCEventRunner(String option) { this.option = option; } public void run() { // fire events for (EventID event : data.getEventIDs(option, OptionType.NPC)) { BetonQuest.event(playerID, event); } new OptionPrinter(option).runTaskAsynchronously(plugin); } } /** * Fires events from the option. Should be called in the main thread. * * @author Jakub Sapalski */ private class PlayerEventRunner extends BukkitRunnable { private String option; public PlayerEventRunner(String option) { this.option = option; } public void run() { // fire events for (EventID event : data.getEventIDs(option, OptionType.PLAYER)) { BetonQuest.event(playerID, event); } new ResponsePrinter(option).runTaskAsynchronously(plugin); } } /** * Prints the NPC response to the player. Should be called asynchronously. * * @author Jakub Sapalski */ private class ResponsePrinter extends BukkitRunnable { private String option; public ResponsePrinter(String option) { this.option = option; } public void run() { // don't forget to select the option prior to printing its text selectOption(data.getPointers(option, OptionType.PLAYER), false); // print to player npc's answer printNPCText(); ConversationOptionEvent event = new ConversationOptionEvent(player, conv, option, conv.option); Bukkit.getPluginManager().callEvent(event); } } /** * Prints the options to the player. Should be called asynchronously. * * @author Jakub Sapalski */ private class OptionPrinter extends BukkitRunnable { private String option; public OptionPrinter(String option) { this.option = option; } public void run() { // print options printOptions(data.getPointers(option, OptionType.NPC)); } } /** * Ends the conversation. Should be called in the main thread. * * @author Jakub Sapalski */ private class ConversationEnder extends BukkitRunnable { public void run() { endConversation(); } } }