Skip to content

Commit dfb4fbf

Browse files
authored
Merge pull request #28 from muunitnocQ/feature/convo-utils
Added a bunch of conversation API related tools
2 parents 393979b + ef60013 commit dfb4fbf

File tree

5 files changed

+362
-0
lines changed

5 files changed

+362
-0
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package me.kodysimpson.simpapi.conversations;
2+
3+
import org.bukkit.conversations.Conversation;
4+
import org.bukkit.conversations.ConversationCanceller;
5+
import org.bukkit.conversations.ConversationContext;
6+
import org.jetbrains.annotations.NotNull;
7+
8+
import java.util.Arrays;
9+
import java.util.List;
10+
11+
//A better version of o.b.c.ExactMatchConversationCanceller
12+
13+
/**
14+
* @author muunitnocQ
15+
*/
16+
final class ArrayMatchCanceller implements ConversationCanceller {
17+
18+
private final boolean caseSensitive;
19+
private final List<String> escapeWords;
20+
21+
ArrayMatchCanceller(boolean caseSensitive, String... escapeWords) {
22+
this(caseSensitive, Arrays.asList(escapeWords));
23+
}
24+
25+
ArrayMatchCanceller(boolean caseSensitive, List<String> escapeWords) {
26+
this.caseSensitive = caseSensitive;
27+
this.escapeWords = escapeWords;
28+
}
29+
30+
@Override
31+
public void setConversation(@NotNull Conversation conversation) {
32+
}
33+
34+
@Override
35+
public boolean cancelBasedOnInput(@NotNull ConversationContext context, @NotNull String input) {
36+
return escapeWords.stream()
37+
.anyMatch(escapeWord -> caseSensitive ? escapeWord.equals(input) : escapeWord.equalsIgnoreCase(input));
38+
}
39+
40+
@Override
41+
public ConversationCanceller clone() {
42+
return new ArrayMatchCanceller(caseSensitive, escapeWords);
43+
}
44+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package me.kodysimpson.simpapi.conversations;
2+
3+
import me.kodysimpson.simpapi.colors.ColorTranslator;
4+
import org.bukkit.conversations.*;
5+
import org.bukkit.entity.Player;
6+
import org.bukkit.plugin.Plugin;
7+
import org.jetbrains.annotations.NotNull;
8+
import org.jetbrains.annotations.Nullable;
9+
import org.jetbrains.annotations.Range;
10+
11+
import java.util.Collections;
12+
import java.util.Map;
13+
14+
/**
15+
* Helper class for construction of conversations.
16+
* Wraps {@link ConversationFactory} to make building conversations with players
17+
* significantly less tedious and boiler-plate prone.
18+
*
19+
* @see ConversationOptions
20+
* @author muunitnocQ
21+
*/
22+
public final class ConversationStarter {
23+
24+
/**
25+
* Constructs a conversation with the first prompt for a given player under the authority of the provided plugin,
26+
* with optionally attached data.
27+
*
28+
* @param plugin The plugin owning this conversation instance
29+
* @param player Who the conversation is for
30+
* @param firstPrompt The prompt to start off on
31+
* @param options Optional fine-grained configuration of the conversation. Refer to {@link ConversationOptions}
32+
* @param initialData Optional session data to start off with. Refer to {@link ConversationContext#getAllSessionData()}
33+
* @return The conversation, not yet started. See {@link Conversation#begin()}
34+
*/
35+
public static @NotNull Conversation getForPlayer(
36+
@NotNull Plugin plugin,
37+
@NotNull Player player,
38+
@NotNull Prompt firstPrompt,
39+
@Nullable ConversationOptions options,
40+
@Nullable Map<Object, Object> initialData
41+
) {
42+
if (options == null) options = ConversationOptions.DEFAULT_OPTIONS;
43+
44+
ConversationFactory factory = new ConversationFactory(plugin)
45+
.withFirstPrompt(firstPrompt)
46+
.withLocalEcho(options.localEcho)
47+
.withPrefix(options.prefix)
48+
.addConversationAbandonedListener(new PrefixedAbandonedListener(options.rawPrefix))
49+
.withConversationCanceller(new ArrayMatchCanceller(options.escapeWordsCaseSensitive, options.escapeWords))
50+
.withInitialSessionData(initialData == null ? Collections.emptyMap() : initialData)
51+
.withTimeout(options.timeOut);
52+
Conversation convo = factory.buildConversation(player);
53+
54+
new ServerReloadListener(plugin, convo);
55+
56+
return convo;
57+
}
58+
59+
//I really want records, koby
60+
//java 8 makes me go grrr
61+
62+
/**
63+
* Represents the configurable options in a conversation.
64+
*
65+
* @see ConversationOptions#ConversationOptions(String, boolean, boolean, int, boolean, String...)
66+
*/
67+
public final static class ConversationOptions {
68+
69+
//Applied if the options for ConversationStarter#getForPlayer is null
70+
private static final ConversationOptions DEFAULT_OPTIONS = new ConversationOptions(
71+
null,
72+
true,
73+
false,
74+
60,
75+
false,
76+
"cancel", "exit", "quit", "escape", "done"
77+
);
78+
79+
/**
80+
* Shown before every line of output in the conversation.
81+
* Supports Minecraft vanilla colors and hex colors in the format &#RRGGBB.
82+
*
83+
* @see ColorTranslator#translateColorCodes(String)
84+
*/
85+
public final ConversationPrefix prefix;
86+
private final String rawPrefix;
87+
public final boolean localEcho;
88+
public final boolean modal;
89+
public final int timeOut;
90+
public final boolean escapeWordsCaseSensitive;
91+
public final String[] escapeWords;
92+
93+
/**
94+
* Describes all options a conversation can have.
95+
*
96+
* @param prefix Shown before every line of output in the conversation
97+
* Defaults to "" if null is passed
98+
* @param localEcho Should the player's input be displayed back to them?
99+
* @param modal Determines if the player can see other players chatting during the conversation
100+
* @param timeOut An automatic time-out in which the conversation will cancel upon receiving no input from the player
101+
* Clamps to 0 as a minimum.
102+
* @param escapeWordsCaseSensitive Should escape words be case sensitive? See next parameter for details
103+
* @param escapeWords Words that, when entered as input, will force the conversation to be abandoned.
104+
*/
105+
public ConversationOptions(
106+
@Nullable String prefix,
107+
boolean localEcho,
108+
boolean modal,
109+
@Range(from = 0, to = Integer.MAX_VALUE) int timeOut,
110+
boolean escapeWordsCaseSensitive,
111+
String... escapeWords
112+
) {
113+
if (prefix == null) {
114+
this.rawPrefix = "";
115+
this.prefix = new NullConversationPrefix();
116+
} else {
117+
this.rawPrefix = ColorTranslator.translateColorCodes(prefix);
118+
this.prefix = (context) -> rawPrefix;
119+
}
120+
121+
this.localEcho = localEcho;
122+
this.modal = modal;
123+
this.timeOut = timeOut;
124+
this.escapeWordsCaseSensitive = escapeWordsCaseSensitive;
125+
126+
if (escapeWords == null || escapeWords.length < 1) {
127+
this.escapeWords = new String[]{
128+
"cancel", "exit", "quit", "escape", "done"
129+
};
130+
} else this.escapeWords = escapeWords;
131+
}
132+
}
133+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package me.kodysimpson.simpapi.conversations;
2+
3+
4+
import me.kodysimpson.simpapi.colors.ColorTranslator;
5+
import org.bukkit.conversations.ConversationContext;
6+
import org.bukkit.conversations.MessagePrompt;
7+
import org.bukkit.conversations.Prompt;
8+
import org.jetbrains.annotations.NotNull;
9+
10+
import java.util.Arrays;
11+
12+
/**
13+
* Convenience class for describing messages that take up more than one line of chat.
14+
* Expected usage encompasses extending this class to define concrete message classes:
15+
* <pre>
16+
* final class InvalidInputMessage extends MultilineMessage {
17+
* public InvalidInputMessage(Prompt previousPrompt, Player player) {
18+
* super(previousPrompt,
19+
* "That is not valid input, " + player.getName(),
20+
* "Please enter input as follows:",
21+
* "x... y... or z",
22+
* "Returning to the previous prompt."
23+
* );
24+
* }
25+
* }
26+
* </pre>
27+
* Or by calling {@link MultilineMessage#MultilineMessage(Prompt, String...)} directly.
28+
*
29+
* @author muunitnocQ
30+
*/
31+
public class MultilineMessage extends MessagePrompt {
32+
33+
private final Prompt end;
34+
private final String[] messages;
35+
36+
public MultilineMessage(Prompt end, String... messages) {
37+
if (messages == null || messages.length == 0) {
38+
throw new IllegalStateException("Invalid multiline message");
39+
}
40+
41+
this.end = end;
42+
this.messages = messages;
43+
}
44+
45+
@Override
46+
public @NotNull String getPromptText(@NotNull ConversationContext context) {
47+
return ColorTranslator.translateColorCodes(messages[0]);
48+
}
49+
50+
@Override
51+
protected Prompt getNextPrompt(@NotNull ConversationContext context) {
52+
if (messages.length == 1) return end;
53+
return new MultilineMessage(end, Arrays.copyOfRange(messages, 1, messages.length));
54+
}
55+
56+
57+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package me.kodysimpson.simpapi.conversations;
2+
3+
import me.kodysimpson.simpapi.colors.ColorTranslator;
4+
import org.bukkit.conversations.ConversationAbandonedEvent;
5+
import org.bukkit.conversations.ConversationAbandonedListener;
6+
import org.bukkit.conversations.ConversationCanceller;
7+
import org.bukkit.conversations.InactivityConversationCanceller;
8+
import org.bukkit.entity.Player;
9+
10+
import java.util.Objects;
11+
12+
//A nice conversation abandoned listener that prefixes messages with the conversation's prefix
13+
//Detects a few different conversation cancellers and writes a smarter message versus "conversation abandoned"
14+
15+
/**
16+
* @author muunitnocQ
17+
*/
18+
final class PrefixedAbandonedListener implements ConversationAbandonedListener {
19+
20+
private final String prefix;
21+
22+
PrefixedAbandonedListener(String prefix) {
23+
this.prefix = prefix;
24+
}
25+
26+
@Override
27+
public void conversationAbandoned(ConversationAbandonedEvent abandonedEvent) {
28+
if (abandonedEvent.gracefulExit()) return;
29+
30+
Player player = (Player) abandonedEvent.getContext().getForWhom();
31+
ConversationCanceller canceller = Objects.requireNonNull(abandonedEvent.getCanceller(), "[m.k.s.c.ArrayMatchCanceller] Canceller is null but gracefulExit is false");
32+
33+
//JEP 406 can't land soon enough
34+
35+
//The player entered one of the escape words.
36+
if (canceller instanceof ArrayMatchCanceller) {
37+
player.sendMessage(tl(prefix + "&9Good bye!"));
38+
return;
39+
}
40+
41+
//Server is reloading, abandon the conversation
42+
if (canceller instanceof ServerReloadListener.ServerReloadCanceller) {
43+
player.sendMessage(tl(prefix + "&9Server reloaded, quitting."));
44+
return;
45+
}
46+
47+
//Conversation timed out
48+
if (canceller instanceof InactivityConversationCanceller) {
49+
player.sendMessage(tl(prefix + "&9Session timed out."));
50+
return;
51+
}
52+
53+
//Unknown canceller
54+
player.sendMessage(tl(prefix + "&9Conversation cancelled by &#FF00FFcosmic energy&9."));
55+
}
56+
57+
private static String tl(String msg) {
58+
return ColorTranslator.translateColorCodes(msg);
59+
}
60+
61+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package me.kodysimpson.simpapi.conversations;
2+
3+
import org.bukkit.Bukkit;
4+
import org.bukkit.conversations.Conversation;
5+
import org.bukkit.conversations.ConversationAbandonedEvent;
6+
import org.bukkit.conversations.ConversationCanceller;
7+
import org.bukkit.conversations.ConversationContext;
8+
import org.bukkit.event.EventHandler;
9+
import org.bukkit.event.Listener;
10+
import org.bukkit.event.server.PluginDisableEvent;
11+
import org.bukkit.plugin.Plugin;
12+
import org.jetbrains.annotations.NotNull;
13+
14+
//Addresses a bug with conversations where, upon server reload, active conversations enter a "zombie" state.
15+
//In this state, the conversation is *still* alive, but the player cannot interact with it normally.
16+
//This class detects when the conversation's owning plugin is disabled (/reload, plugin managers, etc) and abandons
17+
//the associated conversation.
18+
19+
/**
20+
* @author muunitnocQ
21+
*/
22+
final class ServerReloadListener implements Listener {
23+
24+
final ServerReloadCanceller canceller;
25+
private final Class<? extends Plugin> pluginClazz;
26+
27+
ServerReloadListener(Plugin plugin, Conversation convo) {
28+
this.pluginClazz = plugin.getClass();
29+
this.canceller = new ServerReloadCanceller();
30+
31+
convo.getCancellers().add(canceller);
32+
Bukkit.getPluginManager().registerEvents(this, plugin);
33+
}
34+
35+
@EventHandler
36+
public void onReload(PluginDisableEvent event) {
37+
if (event.getPlugin().getClass() != pluginClazz) return;
38+
canceller.forceAbandon();
39+
}
40+
41+
//Dummy implementation save for reloadCancel
42+
static final class ServerReloadCanceller implements ConversationCanceller {
43+
private Conversation conversation;
44+
45+
@Override
46+
public void setConversation(@NotNull Conversation conversation) {
47+
this.conversation = conversation;
48+
}
49+
50+
@Override
51+
public boolean cancelBasedOnInput(@NotNull ConversationContext context, @NotNull String input) {
52+
return false;
53+
}
54+
55+
@Override
56+
public ConversationCanceller clone() {
57+
return this;
58+
}
59+
60+
//A "backdoor" into the ConversationCanceller API so the listener can notify this instance when to abandon
61+
//the conversation.
62+
void forceAbandon() {
63+
conversation.abandon(new ConversationAbandonedEvent(conversation, this));
64+
}
65+
66+
}
67+
}

0 commit comments

Comments
 (0)