diff --git a/Readme.md b/Readme.md index 7b39ceeca..1bde11111 100644 --- a/Readme.md +++ b/Readme.md @@ -16,8 +16,10 @@ default settings. Copy and adjust it to your needs. ## Commands -Your players won't need to remember any commands. All they need to do is click on the various UI elements in the chat. -For example: clicking on a channel will set the channel as active. +### Player Commands + +Your players won't need to remember any commands. All they need to do is click on the various UI elements in the chat, * +e.g. clicking on a channel will set the channel as active*. However, here are the commands if you like typing: | Commands | Alias | Permission | Description | | -------- | ----- | ---------- | ----------- | @@ -26,6 +28,12 @@ For example: clicking on a channel will set the channel as active. | `/schat channel message ` | `/ch ` | `schat.player.channel.quickmessage` | Sends a message to the given channel without switching to it. | | `/tell [message]` | `/m`, `/w`, `/msg`, `/pm`, `/qm`, `/dm` | `schat.player.directmessage` | Sends a message to the given player or opens the conversation. | +### Admin Commands + +| Commands | Alias | Permission | Description | +| -------- | ----- | ---------- | ----------- | +| `/schat reload` | | `schat.admin.reload` | Reloads the sChat config and all channels that have changes. This is non disruptive and will not touch unchanged channels. | + ## Permissions | Permission | Description | @@ -36,6 +44,8 @@ For example: clicking on a channel will set the channel as active. | `schat.player.channel.leave` | Allows the player to leave channels. | | `schat.player.channel.quickmessage` | Enables the player to send quick messages (`/ch `) to channels he is allowed to write in. | | `schat.player.directmessage` | Allows the player to send direct messages (`/dm `) to other players. | +| `schat.admin` | This permission groups all admin permissions nested under the `schat.admin.*` permissions. OPs get this by default. | +| `schat.admin.reload` | Allows performing the `/schat reload` command to reload the plugin. | ### Channel Permissions diff --git a/build.gradle b/build.gradle index 39dd25c2a..64fce1517 100644 --- a/build.gradle +++ b/build.gradle @@ -69,6 +69,13 @@ spigot { 'schat.player.directmessage': true ] } + 'schat.admin' { + description 'Contains all admin permissions for sChat.' + defaults 'op' + children = [ + 'schat.admin.reload': true + ] + } } } diff --git a/src/main/java/net/silthus/chat/Constants.java b/src/main/java/net/silthus/chat/Constants.java index c6ee26739..43ec6efba 100644 --- a/src/main/java/net/silthus/chat/Constants.java +++ b/src/main/java/net/silthus/chat/Constants.java @@ -45,6 +45,7 @@ public final class Constants { public static final String PERMISSION_PLAYER_CHANNEL_LEAVE = "schat.player.channel.leave"; public static final String PERMISSION_PLAYER_CHANNEL_QUICKMESSAGE = "schat.player.channel.quickmessage"; public static final String PERMISSION_PLAYER_DIRECT_MESSAGE = "schat.player.directmessage"; + public static final String PERMISSION_ADMIN_RELOAD = "schat.admin.reload"; public static class Targets { @@ -77,6 +78,7 @@ public static class Language { public static final String JOINED_CHANNEL = "joined-channel"; public static final String LEAVE_CHANNEL = "leave-channel"; public static final String INVALID_CONVERSATION = "invalid-conversation"; + public static final String PLUGIN_RELOADED = "plugin-reloaded"; } public static class Errors { diff --git a/src/main/java/net/silthus/chat/Conversation.java b/src/main/java/net/silthus/chat/Conversation.java index d957cf2f9..6b6612126 100644 --- a/src/main/java/net/silthus/chat/Conversation.java +++ b/src/main/java/net/silthus/chat/Conversation.java @@ -65,6 +65,8 @@ default Type getType() { return Type.fromConversation(this); } + void close(); + enum Type { CHANNEL, DIRECT, diff --git a/src/main/java/net/silthus/chat/SChat.java b/src/main/java/net/silthus/chat/SChat.java index 4ac157f1f..496123988 100644 --- a/src/main/java/net/silthus/chat/SChat.java +++ b/src/main/java/net/silthus/chat/SChat.java @@ -69,6 +69,7 @@ public final class SChat extends JavaPlugin { private PluginConfig pluginConfig; private Metrics metrics; + @Setter(AccessLevel.PACKAGE) private ChannelRegistry channelRegistry; private ChatterManager chatterManager; private ConversationManager conversationManager; @@ -94,6 +95,17 @@ public SChat( testing = true; } + public void reload() { + reloadConfig(); + + final PluginConfig oldConfig = getPluginConfig(); + pluginConfig = PluginConfig.fromConfig(getConfig()); + + if (oldConfig.equals(pluginConfig)) return; + + getChannelRegistry().load(getPluginConfig()); + } + @Override public void onEnable() { if (!isTesting() && isNotPaperMC()) { diff --git a/src/main/java/net/silthus/chat/commands/SChatCommands.java b/src/main/java/net/silthus/chat/commands/SChatCommands.java index 9216a1b9e..3add4c197 100644 --- a/src/main/java/net/silthus/chat/commands/SChatCommands.java +++ b/src/main/java/net/silthus/chat/commands/SChatCommands.java @@ -49,6 +49,13 @@ public SChatCommands(SChat plugin) { this.plugin = plugin; } + @Subcommand("reload") + @CommandPermission(PERMISSION_ADMIN_RELOAD) + public void reload() { + plugin.reload(); + success(PLUGIN_RELOADED); + } + @Subcommand("conversations") public class ConversationCommands extends BaseCommand { diff --git a/src/main/java/net/silthus/chat/config/PluginConfig.java b/src/main/java/net/silthus/chat/config/PluginConfig.java index 06d43a733..3cfb6d9ac 100644 --- a/src/main/java/net/silthus/chat/config/PluginConfig.java +++ b/src/main/java/net/silthus/chat/config/PluginConfig.java @@ -19,8 +19,7 @@ package net.silthus.chat.config; -import lombok.Data; -import lombok.NonNull; +import lombok.*; import lombok.experimental.Accessors; import lombok.extern.java.Log; import net.silthus.chat.Constants; @@ -33,8 +32,10 @@ import static java.util.stream.Collectors.toMap; @Data +@Builder @Accessors(fluent = true) @Log(topic = Constants.PLUGIN_NAME) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public class PluginConfig { public static PluginConfig fromConfig(ConfigurationSection config) { @@ -44,6 +45,7 @@ public static PluginConfig fromConfig(ConfigurationSection config) { private final Defaults defaults; private final ConsoleConfig console; private final PrivateChatConfig privateChat; + @Singular private final Map channels; private PluginConfig(@NonNull ConfigurationSection config) { diff --git a/src/main/java/net/silthus/chat/conversations/AbstractConversation.java b/src/main/java/net/silthus/chat/conversations/AbstractConversation.java index 48c6c94c4..4b438447b 100644 --- a/src/main/java/net/silthus/chat/conversations/AbstractConversation.java +++ b/src/main/java/net/silthus/chat/conversations/AbstractConversation.java @@ -72,4 +72,10 @@ public int compareTo(@NonNull Conversation o) { .thenComparing(Identity::getName) .compare(this, o); } + + @Override + public void close() { + getTargets().forEach(target -> target.unsubscribe(this)); + targets.clear(); + } } diff --git a/src/main/java/net/silthus/chat/conversations/Channel.java b/src/main/java/net/silthus/chat/conversations/Channel.java index 30d3f26c1..63c69000d 100644 --- a/src/main/java/net/silthus/chat/conversations/Channel.java +++ b/src/main/java/net/silthus/chat/conversations/Channel.java @@ -40,7 +40,7 @@ public static Channel channel(String identifier, ChannelConfig config) { return SChat.instance().getChannelRegistry().getOrCreate(identifier, config); } - private final ChannelConfig config; + private ChannelConfig config; Channel(String identifier) { this(identifier, ChannelConfig.defaults()); @@ -48,6 +48,10 @@ public static Channel channel(String identifier, ChannelConfig config) { Channel(String identifier, ChannelConfig config) { super(identifier); + setConfig(config); + } + + public void setConfig(ChannelConfig config) { this.config = config; if (config.name() != null) setDisplayName(Component.text(config.name())); diff --git a/src/main/java/net/silthus/chat/conversations/ChannelRegistry.java b/src/main/java/net/silthus/chat/conversations/ChannelRegistry.java index 34cb941af..f333659a9 100644 --- a/src/main/java/net/silthus/chat/conversations/ChannelRegistry.java +++ b/src/main/java/net/silthus/chat/conversations/ChannelRegistry.java @@ -24,6 +24,7 @@ import lombok.extern.java.Log; import net.kyori.adventure.text.Component; import net.silthus.chat.Constants; +import net.silthus.chat.Conversation; import net.silthus.chat.SChat; import net.silthus.chat.config.ChannelConfig; import net.silthus.chat.config.PluginConfig; @@ -33,7 +34,7 @@ @Log(topic = Constants.PLUGIN_NAME) @AllArgsConstructor -public final class ChannelRegistry implements Iterable { +public class ChannelRegistry implements Iterable { private final SChat plugin; private final Map channels = Collections.synchronizedMap(new HashMap<>()); @@ -77,6 +78,14 @@ public Channel getOrCreate(@NonNull String identifier, ChannelConfig config) { return channels.computeIfAbsent(identifier.toLowerCase(), s -> new Channel(s, config)); } + public Channel get(@NonNull String identifier) { + return channels.get(identifier.toLowerCase()); + } + + public Channel create(@NonNull String identifier, @NonNull ChannelConfig config) { + return channels.compute(identifier, (s, channel) -> new Channel(identifier, config)); + } + public int size() { return channels.size(); } @@ -91,31 +100,39 @@ public boolean contains(Channel channel) { } public void clear() { - channels.values().forEach(channel -> channel.getTargets().forEach(target -> target.unsubscribe(channel))); + channels.values().forEach(Conversation::close); channels.clear(); } public void load(@NonNull PluginConfig config) { - clear(); loadChannels(config); } private void loadChannels(@NonNull PluginConfig config) { - config.channels().entrySet().stream() - .map(entry -> Channel.channel(entry.getKey(), entry.getValue())) - .forEach(this::add); + for (Map.Entry entry : config.channels().entrySet()) { + if (contains(entry.getKey())) { + get(entry.getKey()).setConfig(entry.getValue()); + } else { + create(entry.getKey(), entry.getValue()); + } + } + final Set channelsToRemove = new HashSet<>(channels.keySet()); + channelsToRemove.removeAll(config.channels().keySet()); + channelsToRemove.forEach(this::remove); } public void add(@NonNull Channel channel) { this.channels.put(channel.getName(), channel); } - public boolean remove(@NonNull Channel channel) { - return this.channels.remove(channel.getName(), channel); + public void remove(@NonNull Channel channel) { + remove(channel.getName()); } public Channel remove(String identifier) { if (identifier == null) return null; - return this.channels.remove(identifier.toLowerCase()); + final Channel channel = this.channels.remove(identifier.toLowerCase()); + if (channel != null) channel.close(); + return channel; } } diff --git a/src/main/resources/config.default.yml b/src/main/resources/config.default.yml index 2e509432a..fc8c7af6b 100644 --- a/src/main/resources/config.default.yml +++ b/src/main/resources/config.default.yml @@ -18,9 +18,6 @@ defaults: console: # This is the channel the console will send chat messages to. default_channel: global -private_chats: - # set false to only allow private chats on the active server - global: true # Define your list of channels here. channels: # The id of the channel must be unique across your server network. diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2e509432a..fc8c7af6b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -18,9 +18,6 @@ defaults: console: # This is the channel the console will send chat messages to. default_channel: global -private_chats: - # set false to only allow private chats on the active server - global: true # Define your list of channels here. channels: # The id of the channel must be unique across your server network. diff --git a/src/main/resources/lang_en.yaml b/src/main/resources/lang_en.yaml index 94825d97d..d48a4fb0e 100644 --- a/src/main/resources/lang_en.yaml +++ b/src/main/resources/lang_en.yaml @@ -5,4 +5,5 @@ commands: joined-channel: "&7You joined the channel: &6{channel}&7." leave-channel: "&7You left the channel: &6{channel}&7." invalid-conversation: "The conversation you tried to join does not exist." - cannot-send-to-self: "You can't send messages to yourself." \ No newline at end of file + cannot-send-to-self: "You can't send messages to yourself." + plugin-reloaded: "sChat and all channel configs have been reloaded." \ No newline at end of file diff --git a/src/test/java/net/silthus/chat/SChatTest.java b/src/test/java/net/silthus/chat/SChatTest.java index c737cc475..782436d6c 100644 --- a/src/test/java/net/silthus/chat/SChatTest.java +++ b/src/test/java/net/silthus/chat/SChatTest.java @@ -21,7 +21,11 @@ import co.aikar.commands.BukkitCommandManager; import net.kyori.adventure.text.Component; +import net.silthus.chat.config.ChannelConfig; +import net.silthus.chat.config.PluginConfig; import net.silthus.chat.conversations.Channel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import java.io.File; @@ -29,6 +33,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; class SChatTest extends TestBase { @@ -82,4 +88,47 @@ void writes_defaultConfig() { assertThat(config).exists(); assertThat(defaultConfig).exists(); } + + @Nested + class Reload { + + @BeforeEach + void setUp() { + + plugin.setChannelRegistry(spy(plugin.getChannelRegistry())); + } + + @Test + void reload_loadsNewConfigYAML() { + loadTestConfig("reload-test.yml"); + final PluginConfig oldConfig = plugin.getPluginConfig(); + plugin.reload(); + final PluginConfig newConfig = plugin.getPluginConfig(); + + assertThat(oldConfig).isNotEqualTo(newConfig); + assertThat(newConfig) + .extracting(PluginConfig::defaults) + .extracting(PluginConfig.Defaults::channel) + .extracting( + ChannelConfig::autoJoin, + ChannelConfig::protect, + ChannelConfig::canLeave + ).contains( + true, + true, + false + ); + } + + @Test + void reloadsChannels_ifConfigChanged() { + plugin.reload(); + verify(plugin.getChannelRegistry(), never()).load(any()); + + loadTestConfig("reload-test.yml"); + plugin.reload(); + verify(plugin.getChannelRegistry()).load(any()); + } + } + } \ No newline at end of file diff --git a/src/test/java/net/silthus/chat/TestBase.java b/src/test/java/net/silthus/chat/TestBase.java index 39767a003..065fa3814 100644 --- a/src/test/java/net/silthus/chat/TestBase.java +++ b/src/test/java/net/silthus/chat/TestBase.java @@ -46,10 +46,14 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import java.io.File; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.time.Instant; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Stream; @@ -71,6 +75,7 @@ public void setUp() { plugin.setChatPacketQueue(spy(new ChatPacketQueue(plugin))); setupVaultMock(); setupBungeecordMock(); + plugin.setChannelRegistry(spy(plugin.getChannelRegistry())); } @@ -181,4 +186,10 @@ protected Collection randomMessages(int count) { protected String cleaned(@NonNull String message) { return ChatColor.stripColor(message.stripLeading()); } + + @SneakyThrows + protected void loadTestConfig(String name) { + final File config = new File(plugin.getDataFolder(), "config.yml"); + Files.copy(Objects.requireNonNull(Thread.currentThread().getContextClassLoader().getResourceAsStream(name)), config.toPath(), StandardCopyOption.REPLACE_EXISTING); + } } diff --git a/src/test/java/net/silthus/chat/commands/SChatCommandsTest.java b/src/test/java/net/silthus/chat/commands/SChatCommandsTest.java index 4e4221f6c..46358d75d 100644 --- a/src/test/java/net/silthus/chat/commands/SChatCommandsTest.java +++ b/src/test/java/net/silthus/chat/commands/SChatCommandsTest.java @@ -31,6 +31,9 @@ import static net.kyori.adventure.text.Component.text; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; class SChatCommandsTest extends TestBase { @@ -50,6 +53,17 @@ public void setUp() { player.addAttachment(plugin, Constants.PERMISSION_PLAYER_DIRECT_MESSAGE, true); } + @Test + void reload() { + player.performCommand("schat reload"); + verify(plugin.getChannelRegistry(), never()).load(any()); + + loadTestConfig("reload-test.yml"); + player.addAttachment(plugin, Constants.PERMISSION_ADMIN_RELOAD, true); + player.performCommand("schat reload"); + verify(plugin.getChannelRegistry()).load(any()); + } + @Nested class ChannelCommands { diff --git a/src/test/java/net/silthus/chat/conversations/ChannelRegistryTests.java b/src/test/java/net/silthus/chat/conversations/ChannelRegistryTests.java index 9ef5aca72..3f2ab0354 100644 --- a/src/test/java/net/silthus/chat/conversations/ChannelRegistryTests.java +++ b/src/test/java/net/silthus/chat/conversations/ChannelRegistryTests.java @@ -102,15 +102,10 @@ void remove_removesChannelFromRegistry() { registry.add(channel); assertThat(registry.size()).isOne(); - assertThat(registry.remove(channel)).isTrue(); + registry.remove(channel); assertThat(registry.size()).isZero(); } - @Test - void remove_returnsFalse_ifChannelNotRegistered() { - assertThat(registry.remove(ChatTarget.channel("test"))).isFalse(); - } - @Test void remove_withIdentifier_returnsChannel() { Channel test = ChatTarget.channel("test"); @@ -281,6 +276,18 @@ void clear_unsubscribesAllFromChannel() { assertThat(chatter.getConversations()).doesNotContain(foobar); } + @Test + void remove_closesChannel() { + final Channel channel = createChannel("abc"); + registry.add(channel); + final Chatter chatter = Chatter.of(server.addPlayer()); + channel.subscribe(channel); + + registry.remove(channel); + assertThat(chatter.getConversations()).doesNotContain(channel); + assertThat(channel.getTargets()).doesNotContain(chatter); + } + @Nested class Load { @@ -317,6 +324,38 @@ void load_registersSystemChannel_ifConfigured() { registry.load(config); } + @Test + void load_reloadsConfigOfExistingChannels() { + registry.load(channelConfigBefore()); + assertThat(registry.contains("test1")).isTrue(); + assertThat(registry.contains("test2")).isTrue(); + + registry.load(channelConfigAfter()); + assertThat(registry.getOrCreate("test1")) + .extracting(Channel::getConfig) + .extracting( + ChannelConfig::name, + ChannelConfig::autoJoin + ).contains( + "Foobar", + false + ); + assertThat(registry.contains("test2")).isFalse(); + } + + @Test + void load_doesNotCreateNewChannel_ifChannelExists() { + registry.load(channelConfigBefore()); + Channel channel = registry.get("test1"); + Chatter chatter = ChatTarget.player(server.addPlayer()); + channel.addTarget(chatter); + assertThat(channel.getTargets()).contains(chatter); + + registry.load(channelConfigAfter()); + channel = registry.get("test1"); + assertThat(channel.getTargets()).contains(chatter); + } + private void loadTwoChannels() { MemoryConfiguration cfg = new MemoryConfiguration(); cfg.set("channels.test1.name", "Test 1"); @@ -327,5 +366,20 @@ private void loadTwoChannels() { private void loadFromEmptyChannelConfig() { registry.load(PluginConfig.fromConfig(new MemoryConfiguration())); } + + private PluginConfig channelConfigBefore() { + final PluginConfig config = PluginConfig.builder() + .channel("test1", ChannelConfig.builder().name("Test 1").autoJoin(true).build()) + .channel("test2", ChannelConfig.builder().name("Test 2").format(Format.noFormat()).build()) + .build(); + return config; + } + + private PluginConfig channelConfigAfter() { + final PluginConfig newConfig = PluginConfig.builder() + .channel("test1", ChannelConfig.builder().name("Foobar").autoJoin(false).build()) + .build(); + return newConfig; + } } } diff --git a/src/test/java/net/silthus/chat/identities/ChannelTests.java b/src/test/java/net/silthus/chat/identities/ChannelTests.java index bcde375bf..ca02aa7fb 100644 --- a/src/test/java/net/silthus/chat/identities/ChannelTests.java +++ b/src/test/java/net/silthus/chat/identities/ChannelTests.java @@ -423,6 +423,17 @@ void unsubscribe_removesActiveConversation() { .isNotNull().isNotEqualTo(channel); } + @Test + void close_unsubscribesAllTargets() { + final Channel channel = createChannel(config -> config); + final Chatter chatter = Chatter.of(server.addPlayer()); + chatter.subscribe(channel); + + channel.close(); + assertThat(channel.getTargets()).isEmpty(); + assertThat(chatter.getConversations()).doesNotContain(channel); + } + @Nested @DisplayName("with config") class WithConfig { diff --git a/src/test/resources/reload-test.yml b/src/test/resources/reload-test.yml new file mode 100644 index 000000000..4205b97cc --- /dev/null +++ b/src/test/resources/reload-test.yml @@ -0,0 +1,26 @@ +defaults: + channel: + protect: true + auto_join: true + force: true + console: true + scope: server + worlds: + - world + - world_nether + - world_the_end + range: 100 + format: '[]: ' +console: + default_channel: global +private_chats: + global: true +channels: + global: + name: Global + scope: server + protect: true + force: false + auto_join: false + console: false + format: ': ' \ No newline at end of file