summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-30 13:54:28 +0900
committerGitHub <noreply@github.com>2026-08-30 13:54:28 +0900
commitb697f1cc341cbd9740daa2249c566f1b6bf1952d (patch)
tree6d4d14a5f6cc07d28d8b85fdaedc32496e6f5988
parent6e267a6a0426a5290c8f689c8333f1e9b35e87b7 (diff)
parentebdb8d1940b41bab60d201eb6b0cd6f05efbc6ac (diff)
downloadLunaticChat-b697f1cc341cbd9740daa2249c566f1b6bf1952d.tar.gz
LunaticChat-b697f1cc341cbd9740daa2249c566f1b6bf1952d.tar.bz2
LunaticChat-b697f1cc341cbd9740daa2249c566f1b6bf1952d.zip
Merge pull request #293 from m1sk9/feat/config-reload
feat: Add `/lc reload` command
-rw-r--r--CHANGELOG.md4
-rw-r--r--README.md4
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt2
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt6
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt32
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt7
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt6
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt14
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/LunaticChatCommand.kt8
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommand.kt124
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt105
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloader.kt134
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/MessageFormatHolder.kt25
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt4
-rw-r--r--platform-paper/src/main/resources/config.yml1
-rw-r--r--platform-paper/src/main/resources/languages/en.yml19
-rw-r--r--platform-paper/src/main/resources/languages/ja.yml21
-rw-r--r--platform-paper/src/main/resources/paper-plugin.yml2
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt9
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt8
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt175
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt7
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommandTest.kt150
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt74
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloaderTest.kt187
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManagerTest.kt116
-rw-r--r--website/src/docs/configuration.md22
-rw-r--r--website/src/docs/developers/platform-paper.md5
-rw-r--r--website/src/docs/features/admin.md20
-rw-r--r--website/src/docs/getting-started.md2
-rw-r--r--website/src/docs/permissions.md1
-rw-r--r--website/src/docs/reference/commands.md8
-rw-r--r--website/src/docs/reference/message-format.md2
-rw-r--r--website/src/ja/docs/configuration.md22
-rw-r--r--website/src/ja/docs/developers/platform-paper.md5
-rw-r--r--website/src/ja/docs/features/admin.md20
-rw-r--r--website/src/ja/docs/getting-started.md2
-rw-r--r--website/src/ja/docs/permissions.md1
-rw-r--r--website/src/ja/docs/reference/commands.md8
-rw-r--r--website/src/ja/docs/reference/message-format.md2
40 files changed, 1320 insertions, 44 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 064e585..df84385 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## v1
+### v1.4.0
+
+- Supports reloading configurations while the system is running. Except certain settings, changes can be applied immediately.
+
### v1.3.0
- Paper 26.2 (Minecraft 26.2) is now supported.
diff --git a/README.md b/README.md
index e04d455..04a71b2 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@ Paper and Folia use `LunaticChat-<version>.jar`; Velocity proxies use `LunaticCh
1. Place `LunaticChat-<version>.jar` into the server's `plugins/` directory
2. Start (or restart) the server
3. `plugins/LunaticChat/config.yml` is generated automatically
-4. Adjust the [configuration](#configuration) as needed and restart the server
+4. Adjust the [configuration](#configuration) as needed, then run `/lc reload` or restart the server
### Velocity
@@ -78,7 +78,7 @@ features:
enabled: false # Velocity proxy integration
```
-There is no reload command — **restart the server to apply a change**.
+`/lc reload` re-reads `config.yml` and applies the `messageFormat` settings without a restart. Every other setting is fixed when the server starts, so the command reports which of your changes still need a restart. A file it cannot read in full is refused outright and the running configuration is kept.
Chat output is customizable through `messageFormat` with placeholders such as `{sender}`, `{message}`, `{channel}` and `{server}`:
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt
index 6291553..d12bb2d 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt
+++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt
@@ -13,6 +13,8 @@ sealed class LunaticChatPermissionNode(
object Status : LunaticChatPermissionNode("lunaticchat.command.lc.status")
+ object Reload : LunaticChatPermissionNode("lunaticchat.command.lc.reload")
+
object Channel : LunaticChatPermissionNode("lunaticchat.command.lc.channel")
object ChannelCreate : LunaticChatPermissionNode("lunaticchat.command.lc.channel.create")
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt
index d6d74f7..39f2604 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt
@@ -12,6 +12,7 @@ class LunaticChatPermissionNodeTest {
LunaticChatPermissionNode.Reply,
LunaticChatPermissionNode.Settings,
LunaticChatPermissionNode.Status,
+ LunaticChatPermissionNode.Reload,
LunaticChatPermissionNode.Channel,
LunaticChatPermissionNode.ChannelCreate,
LunaticChatPermissionNode.ChannelList,
@@ -44,8 +45,8 @@ class LunaticChatPermissionNodeTest {
}
@Test
- fun `should have 24 permission nodes`() {
- assertEquals(24, allNodes.size)
+ fun `should have 25 permission nodes`() {
+ assertEquals(25, allNodes.size)
}
@Test
@@ -54,6 +55,7 @@ class LunaticChatPermissionNodeTest {
assertEquals("lunaticchat.command.tell", LunaticChatPermissionNode.Tell.permissionNode)
assertEquals("lunaticchat.command.reply", LunaticChatPermissionNode.Reply.permissionNode)
assertEquals("lunaticchat.command.lc.settings", LunaticChatPermissionNode.Settings.permissionNode)
+ assertEquals("lunaticchat.command.lc.reload", LunaticChatPermissionNode.Reload.permissionNode)
assertEquals("lunaticchat.command.lc.channel.create", LunaticChatPermissionNode.ChannelCreate.permissionNode)
assertEquals("lunaticchat.command.lcv.status", LunaticChatPermissionNode.VelocityStatus.permissionNode)
assertEquals("lunaticchat.spy", LunaticChatPermissionNode.Spy.permissionNode)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
index 85d87cd..6ef0fce 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt
@@ -15,7 +15,9 @@ import dev.m1sk9.lunaticChat.paper.command.setting.SettingKey
import dev.m1sk9.lunaticChat.paper.common.UpdateCheckResult
import dev.m1sk9.lunaticChat.paper.common.UpdateChecker
import dev.m1sk9.lunaticChat.paper.config.ConfigManager
+import dev.m1sk9.lunaticChat.paper.config.ConfigurationReloader
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.listener.EventListenerRegistry
import dev.m1sk9.lunaticChat.paper.velocity.VelocityConnectionManager
@@ -40,6 +42,8 @@ class LunaticChat :
private lateinit var services: ServiceContainer
private lateinit var configuration: LunaticChatConfiguration
private lateinit var serviceInitializer: ServiceInitializer
+ private lateinit var messageFormats: MessageFormatHolder
+ private lateinit var configurationReloader: ConfigurationReloader
private lateinit var pluginScope: PluginCoroutineScope
@@ -61,7 +65,20 @@ class LunaticChat :
override fun onEnable() {
saveDefaultConfig()
- configuration = ConfigManager(logger).loadConfiguration(readConfigFile())
+ val configManager = ConfigManager(logger)
+ configuration = configManager.loadConfiguration(readConfigFile())
+
+ messageFormats = MessageFormatHolder(configuration.messageFormat)
+ configurationReloader =
+ ConfigurationReloader(
+ configManager = configManager,
+ startupConfiguration = configuration,
+ messageFormatHolder = messageFormats,
+ logger = logger,
+ // Throws rather than falling back to an empty document like startup does: a reload
+ // that cannot read the file must leave the running configuration alone.
+ readConfigFile = { configFile.readText() },
+ )
if (configuration.debug) {
logger.warning("LunaticChat is running in debug mode.")
@@ -77,6 +94,7 @@ class LunaticChat :
ServiceInitializer(
plugin = this,
configuration = configuration,
+ messageFormats = messageFormats,
httpClient = httpClient,
logger = logger,
)
@@ -104,6 +122,8 @@ class LunaticChat :
logger.info("LunaticChat disabled.")
}
+ private val configFile get() = dataFolder.resolve("config.yml")
+
/**
* Reads config.yml, or an empty document when it cannot be read.
*
@@ -111,13 +131,11 @@ class LunaticChat :
* nothing there. Handing the parser an empty document starts the plugin on its defaults
* instead of throwing out of [onEnable] and disabling it outright.
*/
- private fun readConfigFile(): String {
- val file = dataFolder.resolve("config.yml")
- return runCatching { file.readText() }.getOrElse { e ->
- logger.severe("Could not read ${file.path}, falling back to defaults: ${e.message}")
+ private fun readConfigFile(): String =
+ runCatching { configFile.readText() }.getOrElse { e ->
+ logger.severe("Could not read ${configFile.path}, falling back to defaults: ${e.message}")
""
}
- }
/**
* Registers all commands based on enabled features.
@@ -149,7 +167,7 @@ class LunaticChat :
services.remotePlayerRegistry,
configuration.features.velocityIntegration.serverName,
),
- LunaticChatCommand(this, settingHandlerRegistry, services.languageManager, configuration),
+ LunaticChatCommand(this, settingHandlerRegistry, services.languageManager, configuration, configurationReloader),
)
// Conditionally register /reply command if quick replies are enabled
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
index cac49c7..28ff7f7 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
@@ -8,6 +8,7 @@ import dev.m1sk9.lunaticChat.paper.chat.handler.ChannelMessageHandler
import dev.m1sk9.lunaticChat.paper.chat.handler.ChannelNotificationHandler
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.converter.ConversionCache
import dev.m1sk9.lunaticChat.paper.converter.GoogleIMEClient
import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter
@@ -51,6 +52,7 @@ private data class ChannelComponents(
class ServiceInitializer(
private val plugin: JavaPlugin,
private val configuration: LunaticChatConfiguration,
+ private val messageFormats: MessageFormatHolder,
private val httpClient: Lazy<HttpClient>,
private val logger: Logger,
) {
@@ -114,7 +116,7 @@ class ServiceInitializer(
// 5. Initialize handlers
val directMessageHandler =
DirectMessageHandler(
- configuration = configuration,
+ messageFormats = messageFormats,
settingsManager = playerSettingsManager,
romanjiConverter = romajiConverter,
languageManager = languageManager,
@@ -278,7 +280,7 @@ class ServiceInitializer(
val messageHandler =
ChannelMessageHandler(
- configuration = configuration,
+ messageFormats = messageFormats,
settingsManager = settingsManager,
channelManager = manager,
languageManager = languageManager,
@@ -358,6 +360,7 @@ class ServiceInitializer(
plugin = plugin,
logger = logger,
configuration = configuration,
+ messageFormats = messageFormats,
cacheSize = configuration.features.velocityIntegration.messageDeduplicationCacheSize,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
index 0155a33..1e87a1f 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
@@ -6,7 +6,7 @@ import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMessageLogger
import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager
import dev.m1sk9.lunaticChat.paper.common.playChannelReceiveNotification
import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification
-import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.i18n.withChatPlaceholders
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
@@ -16,7 +16,7 @@ import org.bukkit.Bukkit
import org.bukkit.entity.Player
class ChannelMessageHandler(
- private val configuration: LunaticChatConfiguration,
+ private val messageFormats: MessageFormatHolder,
private val settingsManager: PlayerSettingsManager?,
private val channelManager: ChannelManager,
private val languageManager: LanguageManager,
@@ -88,7 +88,7 @@ class ChannelMessageHandler(
channelName: String,
message: String,
): Component {
- val format = configuration.messageFormat.channelMessageFormat
+ val format = messageFormats.current.channelMessageFormat
val text =
format.withChatPlaceholders(
"sender" to senderName,
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
index 7eb077a..69f2636 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
@@ -3,7 +3,7 @@ package dev.m1sk9.lunaticChat.paper.chat.handler
import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager
import dev.m1sk9.lunaticChat.paper.common.playDirectMessageNotification
import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification
-import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter
import dev.m1sk9.lunaticChat.paper.converter.convertWithRomaji
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
@@ -37,9 +37,13 @@ sealed interface ReplyTarget {
/**
* Manages direct message state including reply targets.
* Tracks the last player who messaged each player for /reply functionality.
+ *
+ * `/lc reload` can replace the format between two messages, so each method reads it once, at the
+ * top, and passes that one string down. Reading [MessageFormatHolder] again further in would let a
+ * reload land mid-message and show the spies a different format than the two players saw.
*/
class DirectMessageHandler(
- private val configuration: LunaticChatConfiguration,
+ private val messageFormats: MessageFormatHolder,
private val settingsManager: PlayerSettingsManager?,
private val romanjiConverter: RomanjiConverter?,
private val languageManager: LanguageManager,
@@ -136,7 +140,7 @@ class DirectMessageHandler(
val displayMessage = convertIfEnabled(message, senderSettings?.japaneseConversionEnabled == true)
- val format = configuration.messageFormat.directMessageFormat
+ val format = messageFormats.current.directMessageFormat
notifySpies(format, sender.name, recipient.name, message)
@@ -173,7 +177,7 @@ class DirectMessageHandler(
val senderSettings = settingsManager?.getSettings(sender.uniqueId)
val displayMessage = convertIfEnabled(message, senderSettings?.japaneseConversionEnabled == true)
- val format = configuration.messageFormat.directMessageFormat
+ val format = messageFormats.current.directMessageFormat
val recipientDisplay = "$targetName@$targetServerName"
notifySpies(format, sender.name, recipientDisplay, message)
@@ -200,7 +204,7 @@ class DirectMessageHandler(
message: String,
) {
val recipientSettings = settingsManager?.getSettings(recipient.uniqueId)
- val format = configuration.messageFormat.directMessageFormat
+ val format = messageFormats.current.directMessageFormat
val senderDisplay = "$senderName@$sourceServerName"
val userMessage =
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/LunaticChatCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/LunaticChatCommand.kt
index 5b2ff6e..9fbb04b 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/LunaticChatCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/LunaticChatCommand.kt
@@ -8,6 +8,7 @@ import dev.m1sk9.lunaticChat.paper.command.annotation.Permission
import dev.m1sk9.lunaticChat.paper.command.annotation.PlayerOnly
import dev.m1sk9.lunaticChat.paper.command.core.LunaticCommand
import dev.m1sk9.lunaticChat.paper.command.setting.SettingHandlerRegistry
+import dev.m1sk9.lunaticChat.paper.config.ConfigurationReloader
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import io.papermc.paper.command.brigadier.CommandSourceStack
@@ -29,6 +30,7 @@ class LunaticChatCommand(
private val settingHandlerRegistry: SettingHandlerRegistry,
override val languageManager: LanguageManager,
private val configuration: LunaticChatConfiguration,
+ private val configurationReloader: ConfigurationReloader,
) : LunaticCommand(plugin) {
override val description: String
get() = languageManager.getMessage("commandDescription.lc")
@@ -48,6 +50,12 @@ class LunaticChatCommand(
configuration,
).buildAll().forEach { command.then(it) }
+ ReloadCommand(
+ plugin,
+ languageManager,
+ configurationReloader,
+ ).buildAll().forEach { command.then(it) }
+
// Add channel command if channel manager is available
plugin.channelManager?.let { manager ->
plugin.channelMembershipManager?.let { membershipManager ->
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommand.kt
new file mode 100644
index 0000000..c270946
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommand.kt
@@ -0,0 +1,124 @@
+package dev.m1sk9.lunaticChat.paper.command.impl.lc
+
+import com.mojang.brigadier.Command
+import com.mojang.brigadier.builder.LiteralArgumentBuilder
+import dev.m1sk9.lunaticChat.engine.command.CommandResult
+import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
+import dev.m1sk9.lunaticChat.paper.command.core.LunaticSubCommand
+import dev.m1sk9.lunaticChat.paper.config.ConfigurationReloader
+import dev.m1sk9.lunaticChat.paper.config.ReloadResult
+import dev.m1sk9.lunaticChat.paper.config.SettingFallback
+import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
+import dev.m1sk9.lunaticChat.paper.i18n.MessageFormatter
+import io.papermc.paper.command.brigadier.CommandSourceStack
+import io.papermc.paper.command.brigadier.Commands
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.format.NamedTextColor
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Re-reads config.yml and applies the message formats.
+ *
+ * Not annotated `@PlayerOnly`, and it never calls [CommandContext.requirePlayer]: reloading is an
+ * operator's job, and the console and RCON are where operators do it. The parent `/lc` carries a
+ * `@PlayerOnly` that nothing enforces - the parent literal has no executor of its own - so this
+ * node is reachable from the console today. Should that annotation ever be made to bite, it has to
+ * gate the parent's own executor rather than its children, or this command goes with it.
+ */
+class ReloadCommand(
+ plugin: LunaticChat,
+ override val languageManager: LanguageManager,
+ private val reloader: ConfigurationReloader,
+) : LunaticSubCommand(plugin) {
+ override val literal = "reload"
+ override val permissionNode = LunaticChatPermissionNode.Reload
+
+ /**
+ * Guards against two reloads overlapping.
+ *
+ * The holder swap itself is a single volatile write and cannot tear; what this prevents is two
+ * readers of the same file racing to decide which one wins.
+ */
+ private val inProgress = AtomicBoolean(false)
+
+ override fun build(): LiteralArgumentBuilder<CommandSourceStack> =
+ Commands.literal(literal).executes { ctx ->
+ val context = wrapContext(ctx)
+ // config.yml lives under plugins/, which is a network mount often enough that even a
+ // read this small cannot be allowed to happen on the tick thread.
+ plugin.server.asyncScheduler.runNow(plugin) {
+ handleResult(context, execute(context))
+ }
+ Command.SINGLE_SUCCESS
+ }
+
+ internal fun execute(ctx: CommandContext): CommandResult {
+ if (!inProgress.compareAndSet(false, true)) {
+ return fail("reload.inProgress")
+ }
+ return try {
+ report(ctx, reloader.reload())
+ } finally {
+ inProgress.set(false)
+ }
+ }
+
+ private fun report(
+ ctx: CommandContext,
+ result: ReloadResult,
+ ): CommandResult =
+ when (result) {
+ // Which settings moved is left to the server log. The sender only needs to know whether
+ // what they edited is in effect, and a list of dotted keys buried the one bit that
+ // changes what they do next: whether they still have to restart.
+ //
+ // Restart-required wins even when nothing was applied, because the operator did change
+ // something - calling that "no change" would send them looking for a typo they had not
+ // made.
+ is ReloadResult.Applied ->
+ when {
+ result.restartRequired.isNotEmpty() -> ok("reload.restartRequired")
+ result.applied.isNotEmpty() -> ok("reload.success")
+ else -> ok("reload.noChanges")
+ }
+
+ is ReloadResult.InvalidSettings -> {
+ ctx.reply(MessageFormatter.formatError(languageManager.getMessage("reload.invalidSettings")))
+ result.fallbacks.forEach { ctx.reply(fallbackLine(it)) }
+ fail("reload.previousKept")
+ }
+
+ is ReloadResult.InvalidDocument -> {
+ ctx.reply(MessageFormatter.formatError(languageManager.getMessage("reload.invalidDocument")))
+ ctx.reply(reasonLine(result.reason))
+ fail("reload.previousKept")
+ }
+
+ is ReloadResult.Unreadable -> {
+ ctx.reply(MessageFormatter.formatError(languageManager.getMessage("reload.unreadable")))
+ ctx.reply(reasonLine(result.reason))
+ fail("reload.previousKept")
+ }
+ }
+
+ /**
+ * The reason a setting was rejected, as kaml phrased it.
+ *
+ * Built as a component rather than passed to `getMessage` as a placeholder: the message lookup
+ * substitutes placeholders one after another, so a reason carrying its own braces would be
+ * mangled by the next substitution, and MessageFormatter would then colour those braces as if
+ * they were placeholders of ours.
+ */
+ private fun fallbackLine(fallback: SettingFallback): Component =
+ Component
+ .text(" • ", NamedTextColor.GRAY)
+ .append(Component.text("${fallback.settingKey}: ", NamedTextColor.YELLOW))
+ .append(Component.text(fallback.reason, NamedTextColor.GRAY))
+
+ private fun reasonLine(reason: String): Component =
+ Component
+ .text(" • ", NamedTextColor.GRAY)
+ .append(Component.text(reason, NamedTextColor.GRAY))
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
index e01942d..58b1df2 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt
@@ -11,6 +11,29 @@ import com.charleskorn.kaml.YamlPathSegment
import java.util.logging.Level
import java.util.logging.Logger
+/** A setting that could not be read, and so fell back to the default on the data class. */
+data class SettingFallback(
+ val settingKey: String,
+ val reason: String,
+)
+
+/** The outcome of reading config.yml through [ConfigManager.loadStrictly]. */
+sealed interface ConfigLoadResult {
+ data class Success(
+ val configuration: LunaticChatConfiguration,
+ ) : ConfigLoadResult
+
+ /** Every setting that could not be read, so the operator fixes them in one pass. */
+ data class InvalidSettings(
+ val fallbacks: List<SettingFallback>,
+ ) : ConfigLoadResult
+
+ /** The failure cannot be pinned on a setting: not YAML, empty, or not a map at the top level. */
+ data class InvalidDocument(
+ val reason: String,
+ ) : ConfigLoadResult
+}
+
/**
* Reads config.yml into [LunaticChatConfiguration].
*
@@ -18,12 +41,17 @@ import java.util.logging.Logger
* data class. The hand-written mapper it replaced repeated every default in a second place, and
* they had already drifted - checkForUpdates disagreed with both config.yml and the data class,
* and the whole messageLogging block was documented but never read.
+ *
+ * Two readings are offered because startup and reload want opposite things from a bad file:
+ * [loadConfiguration] keeps the server coming up, [loadStrictly] keeps a running server on the
+ * configuration it already has.
*/
class ConfigManager(
private val logger: Logger,
) {
private companion object {
const val UNREADABLE = "config.yml could not be read"
+ const val NOT_YAML = "config.yml is not valid YAML"
}
private val yaml =
@@ -42,42 +70,97 @@ class ConfigManager(
* A setting that cannot be read falls back to its default on its own; the rest of the file is
* still honoured. Only a document that is not YAML at all costs the operator every setting.
*/
- fun loadConfiguration(contents: String): LunaticChatConfiguration {
+ fun loadConfiguration(contents: String): LunaticChatConfiguration =
+ when (val outcome = parse(contents)) {
+ // A file that only holds comments is a valid way of saying "use the defaults", so it
+ // is not reported as a failure the operator has to act on.
+ is ParseOutcome.Empty -> LunaticChatConfiguration()
+ is ParseOutcome.Undecodable -> allDefaults(outcome.what, outcome.cause)
+ is ParseOutcome.Decoded -> {
+ outcome.fallbacks.forEach {
+ logger.warning("${it.settingKey} in config.yml fell back to its default: ${it.reason}")
+ }
+ outcome.configuration
+ }
+ }
+
+ /**
+ * Parses [contents] as config.yml, refusing anything it cannot read in full.
+ *
+ * Reload has an option startup does not: leaving the running configuration alone. Falling a
+ * single setting back to its default here would silently undo a value the operator had set and
+ * report success, so the whole file is rejected and every unreadable setting is named at once.
+ */
+ fun loadStrictly(contents: String): ConfigLoadResult =
+ when (val outcome = parse(contents)) {
+ // Not treated as "use the defaults" like the lenient path does: a reload cannot tell a
+ // comments-only file apart from one caught mid-write by an editor or an SFTP upload,
+ // and resetting every message format over that is the worst of the two mistakes.
+ is ParseOutcome.Empty -> ConfigLoadResult.InvalidDocument("config.yml holds no settings")
+ is ParseOutcome.Undecodable -> ConfigLoadResult.InvalidDocument("${outcome.what}: ${outcome.cause.message}")
+ is ParseOutcome.Decoded ->
+ if (outcome.fallbacks.isEmpty()) {
+ ConfigLoadResult.Success(outcome.configuration)
+ } else {
+ ConfigLoadResult.InvalidSettings(outcome.fallbacks)
+ }
+ }
+
+ private sealed interface ParseOutcome {
+ data class Decoded(
+ val configuration: LunaticChatConfiguration,
+ val fallbacks: List<SettingFallback>,
+ ) : ParseOutcome
+
+ data class Undecodable(
+ val what: String,
+ val cause: Exception,
+ ) : ParseOutcome
+
+ /** Valid YAML that carries no settings. */
+ data object Empty : ParseOutcome
+ }
+
+ private fun parse(contents: String): ParseOutcome {
var document =
try {
// Editors that write a UTF-8 BOM would otherwise leave it on the first key, which
// strictMode = false then drops as an unknown setting without a word.
yaml.parseToYamlNode(contents.removePrefix("\uFEFF"))
} catch (_: EmptyYamlDocumentException) {
- // A file that only holds comments is a valid way of saying "use the defaults", so it
- // is not reported as a failure the operator has to act on.
- return LunaticChatConfiguration()
+ return ParseOutcome.Empty
} catch (e: Exception) {
// Not YamlException: a file the reader rejects before it is YAML at all - one saved
// as UTF-16, or truncated with NUL padding - fails inside the scanner, and letting
// that out of onEnable would disable the plugin over a config file.
- return allDefaults("config.yml is not valid YAML", e)
+ return ParseOutcome.Undecodable(NOT_YAML, e)
}
+ val fallbacks = mutableListOf<SettingFallback>()
+
// Each pass drops exactly one setting, so this terminates: the document strictly shrinks
// until it decodes or there is nothing left to drop.
while (true) {
try {
- return yaml.decodeFromYamlNode(LunaticChatConfiguration.serializer(), document)
+ return ParseOutcome.Decoded(yaml.decodeFromYamlNode(LunaticChatConfiguration.serializer(), document), fallbacks)
} catch (e: YamlException) {
// kaml rejects the document as a whole, so without this one unreadable value would
// lose every other setting in the file - a regression against the hand-written
- // mapper, which defaulted per key.
+ // mapper, which defaulted per key. Pruning also lets the strict reading report
+ // every bad setting at once instead of one per attempt.
val setting = e.path.settingKeys()
+ // An empty path means the failure is not under a key we can drop - the document is
+ // not a map at the top level - and pruning nothing would spin forever.
val remaining =
document.without(setting)
- ?: return allDefaults(UNREADABLE, e)
- logger.warning("${setting.joinToString(".")} in config.yml fell back to its default: ${e.message}")
+ ?: return ParseOutcome.Undecodable(UNREADABLE, e)
+ fallbacks += SettingFallback(setting.joinToString("."), e.message ?: e.toString())
document = remaining
} catch (e: Exception) {
// A serializer can fail without kaml turning it into a YamlException, and there is
- // no path to prune a single setting by without one.
- return allDefaults(UNREADABLE, e)
+ // no path to prune a single setting by without one. No config.yml reaches this -
+ // only a serializer bug does - so it is deliberately left without a test.
+ return ParseOutcome.Undecodable(UNREADABLE, e)
}
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloader.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloader.kt
new file mode 100644
index 0000000..efdd5ef
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloader.kt
@@ -0,0 +1,134 @@
+package dev.m1sk9.lunaticChat.paper.config
+
+import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
+import java.util.logging.Logger
+
+/** What a reload did to the running configuration. */
+sealed interface ReloadResult {
+ /**
+ * config.yml was read in full.
+ *
+ * @property applied settings now in effect that were not before.
+ * @property restartRequired settings the file changed that the running server cannot pick up.
+ */
+ data class Applied(
+ val applied: List<String>,
+ val restartRequired: List<String>,
+ ) : ReloadResult
+
+ data class InvalidSettings(
+ val fallbacks: List<SettingFallback>,
+ ) : ReloadResult
+
+ data class InvalidDocument(
+ val reason: String,
+ ) : ReloadResult
+
+ data class Unreadable(
+ val reason: String,
+ ) : ReloadResult
+}
+
+/**
+ * Re-reads config.yml and applies what can be applied without restarting the server.
+ *
+ * Only [MessageFormatConfig] is applied. Everything else in config.yml decides something that is
+ * settled once, at startup: which services exist, which commands and listeners are registered,
+ * which files are opened. Paper registers commands only through its `COMMANDS` lifecycle event,
+ * Bukkit offers no way to unregister a listener that was registered for a plugin, and the cache
+ * saver retains no task handle to cancel - so rebuilding the services would leave the old ones
+ * still wired up. Rather than pretend, a changed setting the server cannot pick up is reported
+ * back to the operator as needing a restart.
+ */
+class ConfigurationReloader(
+ private val configManager: ConfigManager,
+ private val startupConfiguration: LunaticChatConfiguration,
+ private val messageFormatHolder: MessageFormatHolder,
+ private val logger: Logger,
+ private val readConfigFile: () -> String,
+) {
+ internal companion object {
+ /**
+ * The settings a reload applies, by leaf.
+ *
+ * Leaves rather than the whole block, because the operator wants to see which format they
+ * just changed. [MessageFormatConfig] is a closed set of three strings and a test fails if
+ * it grows without this table growing with it.
+ */
+ internal val APPLIED: List<Pair<String, (MessageFormatConfig) -> Any?>> =
+ listOf(
+ "messageFormat.directMessageFormat" to { it: MessageFormatConfig -> it.directMessageFormat },
+ "messageFormat.channelMessageFormat" to { it: MessageFormatConfig -> it.channelMessageFormat },
+ "messageFormat.crossServerGlobalChatFormat" to { it: MessageFormatConfig -> it.crossServerGlobalChatFormat },
+ )
+
+ /**
+ * The settings a reload cannot apply, by block.
+ *
+ * Blocks rather than leaves: the comparison rides on the data class equals, so a leaf added
+ * to any of these is covered without touching this table.
+ */
+ internal val RESTART_REQUIRED: List<Pair<String, (LunaticChatConfiguration) -> Any?>> =
+ listOf(
+ "features.quickReplies" to { it: LunaticChatConfiguration -> it.features.quickReplies },
+ "features.japaneseConversion" to { it: LunaticChatConfiguration -> it.features.japaneseConversion },
+ "features.channelChat" to { it: LunaticChatConfiguration -> it.features.channelChat },
+ "features.velocityIntegration" to { it: LunaticChatConfiguration -> it.features.velocityIntegration },
+ "debug" to { it: LunaticChatConfiguration -> it.debug },
+ "userSettingsFilePath" to { it: LunaticChatConfiguration -> it.userSettingsFilePath },
+ "checkForUpdates" to { it: LunaticChatConfiguration -> it.checkForUpdates },
+ "language" to { it: LunaticChatConfiguration -> it.language },
+ )
+ }
+
+ /**
+ * Reads config.yml and applies the message formats, leaving the running configuration untouched
+ * when the file cannot be read in full.
+ */
+ fun reload(): ReloadResult {
+ // saveDefaultConfig() is deliberately not called first: a config.yml that has been deleted
+ // or is momentarily absent would be rewritten with the defaults and then applied, which is
+ // the one outcome an operator running a reload cannot have meant.
+ val contents =
+ runCatching(readConfigFile).getOrElse { e ->
+ logger.warning("/lc reload could not read config.yml: ${e.message}")
+ return ReloadResult.Unreadable(e.message ?: e.toString())
+ }
+
+ return when (val loaded = configManager.loadStrictly(contents)) {
+ is ConfigLoadResult.InvalidSettings -> {
+ logger.warning(
+ "/lc reload rejected config.yml; these settings could not be read: " +
+ loaded.fallbacks.joinToString(", ") { it.settingKey },
+ )
+ ReloadResult.InvalidSettings(loaded.fallbacks)
+ }
+
+ is ConfigLoadResult.InvalidDocument -> {
+ logger.warning("/lc reload rejected config.yml: ${loaded.reason}")
+ ReloadResult.InvalidDocument(loaded.reason)
+ }
+
+ is ConfigLoadResult.Success -> apply(loaded.configuration)
+ }
+ }
+
+ private fun apply(incoming: LunaticChatConfiguration): ReloadResult {
+ // The two lists are measured against different baselines on purpose. What a reload applies
+ // is new relative to the formats currently in effect, which an earlier reload may already
+ // have moved; what needs a restart is new relative to what the server actually started on,
+ // which no reload can move.
+ val applied = APPLIED.filter { (_, read) -> read(messageFormatHolder.current) != read(incoming.messageFormat) }.map { it.first }
+ val restartRequired = RESTART_REQUIRED.filter { (_, read) -> read(startupConfiguration) != read(incoming) }.map { it.first }
+
+ messageFormatHolder.replace(incoming.messageFormat)
+
+ // Logged as well as reported to the sender, so a format change can still be dated from the
+ // server log long after the chat message that announced it has scrolled away.
+ logger.info(
+ "Reloaded config.yml. Applied: ${applied.ifEmpty { listOf("(nothing)") }.joinToString(", ")}. " +
+ "Needs a restart: ${restartRequired.ifEmpty { listOf("(nothing)") }.joinToString(", ")}",
+ )
+ return ReloadResult.Applied(applied, restartRequired)
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/MessageFormatHolder.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/MessageFormatHolder.kt
new file mode 100644
index 0000000..2f077f6
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/MessageFormatHolder.kt
@@ -0,0 +1,25 @@
+package dev.m1sk9.lunaticChat.paper.config
+
+import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
+
+/**
+ * The message formats currently in effect, which `/lc reload` may replace while the server runs.
+ *
+ * Deliberately narrower than [LunaticChatConfiguration]: nothing else in config.yml can be applied
+ * without a restart, because the services that read it capture their values when they are built,
+ * and neither commands, event listeners nor scheduled tasks can be re-registered at runtime. A
+ * holder over the whole configuration would let a reload swap only part of it, leaving a tree that
+ * matches no version of the file, and would blur which values a reader is allowed to trust. Taking
+ * this type says the value is live; taking [LunaticChatConfiguration] says it was frozen at startup.
+ */
+class MessageFormatHolder(
+ initial: MessageFormatConfig,
+) {
+ @Volatile
+ var current: MessageFormatConfig = initial
+ private set
+
+ fun replace(next: MessageFormatConfig) {
+ current = next
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt
index 19668be..281a2dc 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt
@@ -4,6 +4,7 @@ import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage
import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageChannel
import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.i18n.withChatPlaceholders
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
@@ -24,6 +25,7 @@ class CrossServerChatManager(
private val plugin: Plugin,
private val logger: Logger,
private val configuration: LunaticChatConfiguration,
+ private val messageFormats: MessageFormatHolder,
private val cacheSize: Int = 100,
) {
private val processedMessages = MessageDeduplicationCache(cacheSize, logger, "global chat")
@@ -125,7 +127,7 @@ class CrossServerChatManager(
* @return Formatted Component
*/
private fun formatCrossServerMessage(message: PluginMessage.GlobalChatMessage): Component {
- val format = configuration.messageFormat.crossServerGlobalChatFormat
+ val format = messageFormats.current.crossServerGlobalChatFormat
val formattedText =
format.withChatPlaceholders(
"server" to message.serverName,
diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml
index 37bfdc6..be90bc3 100644
--- a/platform-paper/src/main/resources/config.yml
+++ b/platform-paper/src/main/resources/config.yml
@@ -95,6 +95,7 @@ features:
# {server} - The name of the server (only for Velocity cross-server chat)
# ----------------------------------------------
+# These are the only settings /lc reload applies without a restart.
messageFormat:
# Configure the format for direct messages sent via /tell or /msg
directMessageFormat: "§7[§e{sender} §7>> §e{recipient}§7] §f{message}"
diff --git a/platform-paper/src/main/resources/languages/en.yml b/platform-paper/src/main/resources/languages/en.yml
index f65474a..f5e6b8f 100644
--- a/platform-paper/src/main/resources/languages/en.yml
+++ b/platform-paper/src/main/resources/languages/en.yml
@@ -65,6 +65,25 @@ status:
settings:
availableValues: "Available settings value: {values}"
+reload:
+ success: "Reloaded the configuration file."
+ restartRequired: "Reloaded the configuration file. A server restart is required to apply the changes."
+ noChanges: "There was no change to reload."
+ inProgress: "A reload is already running."
+ # invalidSettings / invalidDocument / unreadable are headings. /lc reload prints the setting it
+ # could not read, and the reason, on the lines beneath them.
+ #
+ # Those detail lines are NOT translated. The reason comes from the YAML parser and stays English
+ # whatever language is selected, so a parser update cannot leave a stale translation behind here.
+ # It also cannot be carried as a {placeholder}: a reason holding braces of its own would be broken
+ # by the next substitution, and the braces would then be coloured as if they were ours.
+ # See ReloadCommand.fallbackLine / reasonLine.
+ invalidSettings: "Reload aborted. The following settings could not be read:"
+ invalidDocument: "Reload aborted. config.yml could not be read as a configuration file:"
+ unreadable: "Reload aborted. The file could not be read"
+ # The closing line shared by all three failures above.
+ previousKept: "The configuration the server started with is still in use."
+
channel:
help:
header: "--- Channel Commands ---"
diff --git a/platform-paper/src/main/resources/languages/ja.yml b/platform-paper/src/main/resources/languages/ja.yml
index 764951b..2b85c87 100644
--- a/platform-paper/src/main/resources/languages/ja.yml
+++ b/platform-paper/src/main/resources/languages/ja.yml
@@ -56,7 +56,7 @@ status:
health:
ok: "正常"
degraded: "問題あり"
- degradedHover: "Velocity 連携が有効ですが接続に失敗しました。/lcv status を確認してください。"
+ degradedHover: "Velocity 連携が有効ですが接続に失敗しました。/lcv status を確認してください"
releaseChannel: "リリースチャンネル"
channel:
stable: "安定版"
@@ -65,6 +65,25 @@ status:
settings:
availableValues: "使用可能な設定値: {values}"
+reload:
+ success: "設定ファイルを再読み込みしました"
+ restartRequired: "設定ファイルを再読み込みしました。この変更の適用にはサーバーの再起動が必要です"
+ noChanges: "再読み込みが必要な変更はありませんでした"
+ inProgress: "再読み込みはすでに実行中です"
+ # invalidSettings / invalidDocument / unreadable は見出しです./lc reload はこの下に,読み取れな
+ # かった設定とその理由を 1 行ずつ表示します.
+ #
+ # その明細行は翻訳されません.理由は YAML パーサーが返す文言をそのまま出しており,どの言語を
+ # 選んでいても英語のままです.こうしておかないと,パーサーの更新で文言が変わるたびにここの翻訳
+ # が実態と食い違います.{placeholder} として渡すこともできません.理由自体が波括弧を含む場合,
+ # 後続の置換で壊れたうえ,その波括弧が LunaticChat のプレースホルダとして着色されるためです.
+ # ReloadCommand.fallbackLine / reasonLine を参照してください.
+ invalidSettings: "再読み込みに失敗しました。次の設定を読み取れません:"
+ invalidDocument: "再読み込みを中止しました。config.yml を設定ファイルとして読み取れません:"
+ unreadable: "再読み込みを中止しました。ファイルを読み取れません"
+ # 上記 3 つの失敗に共通する締めの行.
+ previousKept: "サーバー起動時の設定をそのまま使用します"
+
channel:
help:
header: "--- チャンネルコマンド ---"
diff --git a/platform-paper/src/main/resources/paper-plugin.yml b/platform-paper/src/main/resources/paper-plugin.yml
index 3b56cda..8f2f625 100644
--- a/platform-paper/src/main/resources/paper-plugin.yml
+++ b/platform-paper/src/main/resources/paper-plugin.yml
@@ -59,3 +59,5 @@ permissions:
default: op
lunaticchat.command.lcv.status:
default: op
+ lunaticchat.command.lc.reload:
+ default: op
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt
index 58c1e30..9c241bc 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/ServiceShutdownTest.kt
@@ -19,7 +19,14 @@ class ServiceShutdownTest {
private fun shutdown(stoppables: List<StoppableService>): TestUtils.TestLogger {
val logger = TestUtils.TestLogger()
- val initializer = ServiceInitializer(mockk(relaxed = true), TestUtils.createTestConfiguration(), lazy { mockk() }, logger)
+ val initializer =
+ ServiceInitializer(
+ plugin = mockk(relaxed = true),
+ configuration = TestUtils.createTestConfiguration(),
+ messageFormats = TestUtils.createTestMessageFormats(),
+ httpClient = lazy { mockk() },
+ logger = logger,
+ )
initializer.shutdown(
ServiceContainer(
languageManager = mockk(),
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
index 420391f..2f65620 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
@@ -5,6 +5,7 @@ import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMember
import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole
import dev.m1sk9.lunaticChat.engine.settings.PlayerChatSettings
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig
import dev.m1sk9.lunaticChat.paper.config.key.ConversionCacheConfig
import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
@@ -106,6 +107,7 @@ object TestUtils {
MessageFormatConfig(
directMessageFormat = "§7[§e{sender} §7>> §e{recipient}§7] §f{message}",
channelMessageFormat = "§7[§b#{channel}§7] §e{sender}: §f{message}",
+ crossServerGlobalChatFormat = "§7[§6{server}§7] §e{sender}: §f{message}",
),
debug = debug,
checkForUpdates = checkForUpdates,
@@ -114,6 +116,12 @@ object TestUtils {
)
/**
+ * Creates a holder over the formats of [createTestConfiguration].
+ */
+ fun createTestMessageFormats(configuration: LunaticChatConfiguration = createTestConfiguration()): MessageFormatHolder =
+ MessageFormatHolder(configuration.messageFormat)
+
+ /**
* Creates a default player settings for testing.
*/
fun createTestPlayerSettings(
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt
new file mode 100644
index 0000000..dff75b9
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt
@@ -0,0 +1,175 @@
+package dev.m1sk9.lunaticChat.paper.chat.handler
+
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelContext
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMessageLogEntry
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager
+import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMessageLogger
+import dev.m1sk9.lunaticChat.paper.common.SoundCollector
+import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
+import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
+import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
+import io.ktor.util.logging.Logger
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkStatic
+import io.mockk.verify
+import net.kyori.adventure.sound.Sound
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.TextComponent
+import org.bukkit.Bukkit
+import java.util.UUID
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class ChannelMessageHandlerTest {
+ private val senderId = TestUtils.createTestUUID(1)
+ private val memberId = TestUtils.createTestUUID(2)
+ private val channel = TestUtils.createTestChannel(id = "ch-1", name = "general")
+
+ private val messageFormats = TestUtils.createTestMessageFormats()
+ private val channelManager = mockk<ChannelManager>(relaxed = true)
+ private val languageManager = mockk<LanguageManager>(relaxed = true)
+ private val logger = mockk<Logger>(relaxed = true)
+
+ private val sender = TestUtils.createMockPlayer(uuid = senderId, name = "Alice")
+ private val member = TestUtils.createMockPlayer(uuid = memberId, name = "Bob")
+
+ @BeforeTest
+ fun setUp() {
+ mockkStatic(Bukkit::class)
+ every { Bukkit.getPlayer(any<UUID>()) } returns null
+ every { Bukkit.getPlayer(senderId) } returns sender
+ every { Bukkit.getPlayer(memberId) } returns member
+ every { channelManager.getPlayerChannelContext(senderId) } returns
+ ChannelContext(
+ channel = channel,
+ members =
+ listOf(
+ TestUtils.createTestChannelMember(channelId = channel.id, playerId = senderId),
+ TestUtils.createTestChannelMember(channelId = channel.id, playerId = memberId),
+ ),
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ unmockkStatic(Bukkit::class)
+ }
+
+ private fun handler(
+ settingsManager: PlayerSettingsManager? = null,
+ messageLogger: ChannelMessageLogger? = null,
+ ) = ChannelMessageHandler(messageFormats, settingsManager, channelManager, languageManager, messageLogger, logger)
+
+ @Test
+ fun `a player with no active channel is not handled`() {
+ every { channelManager.getPlayerChannelContext(senderId) } returns null
+
+ assertFalse(handler().sendChannelMessage(sender, "hello"))
+ verify(exactly = 0) { member.sendMessage(any<Component>()) }
+ }
+
+ @Test
+ fun `every online member receives the message in the configured channel format`() {
+ val delivered = slot<Component>()
+
+ assertTrue(handler().sendChannelMessage(sender, "hello"))
+
+ verify { member.sendMessage(capture(delivered)) }
+ assertEquals("§7[§b#general§7] §eAlice: §fhello", assertIs<TextComponent>(delivered.captured).content())
+ }
+
+ @Test
+ fun `a format replaced while the server runs applies to the next message`() {
+ messageFormats.replace(MessageFormatConfig(channelMessageFormat = "<{channel}> {sender}: {message}"))
+ val delivered = slot<Component>()
+
+ handler().sendChannelMessage(sender, "hello")
+
+ verify { member.sendMessage(capture(delivered)) }
+ assertEquals("<general> Alice: hello", assertIs<TextComponent>(delivered.captured).content())
+ }
+
+ @Test
+ fun `a member who went offline receives nothing`() {
+ val offline = TestUtils.createMockPlayer(uuid = memberId, name = "Bob", isOnline = false)
+ every { Bukkit.getPlayer(memberId) } returns offline
+
+ assertTrue(handler().sendChannelMessage(sender, "hello"))
+
+ verify(exactly = 0) { offline.sendMessage(any<Component>()) }
+ }
+
+ @Test
+ fun `a member who left the server receives nothing`() {
+ every { Bukkit.getPlayer(memberId) } returns null
+
+ assertTrue(handler().sendChannelMessage(sender, "hello"))
+
+ verify(exactly = 0) { member.sendMessage(any<Component>()) }
+ }
+
+ @Test
+ fun `the sender hears the send notification when it is enabled`() {
+ val settingsManager = mockk<PlayerSettingsManager>()
+ every { settingsManager.getSettings(any()) } returns
+ TestUtils.createTestPlayerSettings(channelMessageNotificationEnabled = true)
+
+ handler(settingsManager = settingsManager).sendChannelMessage(sender, "hello")
+
+ verify { sender.playSound(SoundCollector.LUNATIC_POP_SOUND) }
+ }
+
+ @Test
+ fun `the send notification is silent when the sender disabled it`() {
+ val settingsManager = mockk<PlayerSettingsManager>()
+ every { settingsManager.getSettings(any()) } returns
+ TestUtils.createTestPlayerSettings(channelMessageNotificationEnabled = false)
+
+ handler(settingsManager = settingsManager).sendChannelMessage(sender, "hello")
+
+ verify(exactly = 0) { sender.playSound(SoundCollector.LUNATIC_POP_SOUND) }
+ }
+
+ @Test
+ fun `only members other than the sender hear the receive notification`() {
+ val settingsManager = mockk<PlayerSettingsManager>()
+ every { settingsManager.getSettings(any()) } returns
+ TestUtils.createTestPlayerSettings(channelMessageNotificationEnabled = true)
+
+ handler(settingsManager = settingsManager).sendChannelMessage(sender, "hello")
+
+ verify { member.playSound(SoundCollector.LUNATIC_BELL_SOUND) }
+ verify(exactly = 0) { sender.playSound(SoundCollector.LUNATIC_BELL_SOUND) }
+ }
+
+ @Test
+ fun `no notification is played when player settings are unavailable`() {
+ handler().sendChannelMessage(sender, "hello")
+
+ verify(exactly = 0) { sender.playSound(any<Sound>()) }
+ verify(exactly = 0) { member.playSound(any<Sound>()) }
+ }
+
+ @Test
+ fun `the message is written to the channel log`() {
+ val messageLogger = mockk<ChannelMessageLogger>(relaxed = true)
+ val entry = slot<ChannelMessageLogEntry>()
+
+ handler(messageLogger = messageLogger).sendChannelMessage(sender, "hello")
+
+ verify { messageLogger.logMessage(capture(entry)) }
+ assertEquals(senderId.toString(), entry.captured.playerId)
+ assertEquals("Alice", entry.captured.playerName)
+ assertEquals(channel.id, entry.captured.channelId)
+ assertEquals("hello", entry.captured.message)
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
index 1edef92..0c758ed 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
@@ -2,6 +2,7 @@ package dev.m1sk9.lunaticChat.paper.chat.handler
import dev.m1sk9.lunaticChat.engine.protocol.PresenceEntry
import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
@@ -32,7 +33,7 @@ class DirectMessageHandlerTest {
): DirectMessageHandler {
val config = configuration ?: TestUtils.createTestConfiguration()
val langManager = languageManager ?: mockk<LanguageManager>(relaxed = true)
- return DirectMessageHandler(config, settingsManager, romanjiConverter, langManager)
+ return DirectMessageHandler(MessageFormatHolder(config.messageFormat), settingsManager, romanjiConverter, langManager)
}
@Test
@@ -117,7 +118,7 @@ class DirectMessageHandlerTest {
fun `handler can be created with injected configuration`() {
val config = TestUtils.createTestConfiguration(debug = true)
val languageManager = mockk<LanguageManager>(relaxed = true)
- val handler = DirectMessageHandler(config, null, null, languageManager)
+ val handler = DirectMessageHandler(MessageFormatHolder(config.messageFormat), null, null, languageManager)
// Handler should accept configuration via constructor (DI pattern)
// This validates Issue #1 refactoring - ConfigManager DI
@@ -231,7 +232,7 @@ class DirectMessageHandlerTest {
every { settingsManager.getSettings(any()) } returns TestUtils.createTestPlayerSettings()
- val handler = DirectMessageHandler(config, settingsManager, romanjiConverter, languageManager)
+ val handler = DirectMessageHandler(MessageFormatHolder(config.messageFormat), settingsManager, romanjiConverter, languageManager)
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommandTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommandTest.kt
new file mode 100644
index 0000000..ac8da0e
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommandTest.kt
@@ -0,0 +1,150 @@
+package dev.m1sk9.lunaticChat.paper.command.impl.lc
+
+import com.charleskorn.kaml.Yaml
+import dev.m1sk9.lunaticChat.engine.command.CommandResult
+import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
+import dev.m1sk9.lunaticChat.paper.config.ConfigManager
+import dev.m1sk9.lunaticChat.paper.config.ConfigurationReloader
+import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
+import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
+import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import io.papermc.paper.command.brigadier.CommandSourceStack
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+@Suppress("UnstableApiUsage")
+class ReloadCommandTest {
+ private val startup = TestUtils.createTestConfiguration()
+ private val holder = MessageFormatHolder(startup.messageFormat)
+ private val languageManager =
+ mockk<LanguageManager>(relaxed = true).also {
+ every { it.getMessage(any(), any()) } answers { firstArg() }
+ }
+
+ private fun commandReading(contents: () -> String) =
+ ReloadCommand(
+ plugin = mockk<LunaticChat>(relaxed = true),
+ languageManager = languageManager,
+ reloader =
+ ConfigurationReloader(
+ configManager = ConfigManager(TestUtils.TestLogger()),
+ startupConfiguration = startup,
+ messageFormatHolder = holder,
+ logger = TestUtils.TestLogger(),
+ readConfigFile = contents,
+ ),
+ )
+
+ /** A console sender: no player behind it, which the command must never require. */
+ private fun consoleContext(): CommandContext = mockk<CommandContext>(relaxed = true)
+
+ private fun sourceWith(
+ permission: String,
+ granted: Boolean,
+ ): CommandSourceStack =
+ mockk<CommandSourceStack>().also {
+ every { it.sender.hasPermission(permission) } returns granted
+ }
+
+ @Test
+ fun `the node is gated on the reload permission`() {
+ val node = commandReading { "" }.buildAll().single()
+
+ assertTrue(node.requirement.test(sourceWith("lunaticchat.command.lc.reload", granted = true)))
+ assertFalse(node.requirement.test(sourceWith("lunaticchat.command.lc.reload", granted = false)))
+ }
+
+ @Test
+ fun `an applied change reports success, without naming the settings`() {
+ val ctx = consoleContext()
+
+ val result =
+ commandReading {
+ Yaml.default.encodeToString(
+ LunaticChatConfiguration.serializer(),
+ startup.copy(messageFormat = startup.messageFormat.copy(directMessageFormat = "new {message}")),
+ )
+ }.execute(ctx)
+
+ assertIs<CommandResult.SuccessWithMessage>(result)
+ assertEquals("reload.success", result.message)
+ assertEquals("new {message}", holder.current.directMessageFormat)
+ // The one message handleResult sends is the whole reply; no per-setting lines.
+ verify(exactly = 0) { ctx.reply(any()) }
+ }
+
+ @Test
+ fun `a change that cannot take effect asks for a restart`() {
+ val result =
+ commandReading {
+ Yaml.default.encodeToString(
+ LunaticChatConfiguration.serializer(),
+ startup.copy(features = startup.features.copy(channelChat = startup.features.channelChat.copy(enabled = true))),
+ )
+ }.execute(consoleContext())
+
+ assertIs<CommandResult.SuccessWithMessage>(result)
+ assertEquals("reload.restartRequired", result.message)
+ }
+
+ @Test
+ fun `a change needing a restart is not reported as no change, even when nothing was applied`() {
+ // Nothing moved, but the operator did edit the file: telling them there was no change would
+ // send them hunting for a typo they had not made.
+ val result =
+ commandReading {
+ Yaml.default.encodeToString(LunaticChatConfiguration.serializer(), startup.copy(debug = !startup.debug))
+ }.execute(consoleContext())
+
+ assertIs<CommandResult.SuccessWithMessage>(result)
+ assertEquals("reload.restartRequired", result.message)
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a file with nothing to apply says so instead of claiming success`() {
+ val unchanged = Yaml.default.encodeToString(LunaticChatConfiguration.serializer(), startup)
+
+ val result = commandReading { unchanged }.execute(consoleContext())
+
+ assertIs<CommandResult.SuccessWithMessage>(result)
+ assertEquals("reload.noChanges", result.message)
+ }
+
+ @Test
+ fun `an unreadable setting fails the command and names the setting`() {
+ val ctx = consoleContext()
+
+ val result = commandReading { "debug: maybe" }.execute(ctx)
+
+ assertIs<CommandResult.Failure>(result)
+ assertEquals("reload.previousKept", result.message)
+ assertEquals(startup.messageFormat, holder.current)
+ // The headline plus one line per rejected setting.
+ verify(exactly = 2) { ctx.reply(any()) }
+ }
+
+ @Test
+ fun `a document that is not a settings file fails the command`() {
+ val result = commandReading { "# only a comment" }.execute(consoleContext())
+
+ assertIs<CommandResult.Failure>(result)
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a file that cannot be read fails the command`() {
+ val result = commandReading { throw java.io.IOException("gone") }.execute(consoleContext())
+
+ assertIs<CommandResult.Failure>(result)
+ assertEquals(startup.messageFormat, holder.current)
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
index fc7afc9..58afa3d 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt
@@ -5,6 +5,7 @@ import dev.m1sk9.lunaticChat.paper.i18n.Language
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
@@ -17,6 +18,8 @@ import kotlin.test.assertTrue
class ConfigManagerTest {
private fun load(yaml: String) = ConfigManager(TestUtils.TestLogger()).loadConfiguration(yaml)
+ private fun loadStrictly(yaml: String) = ConfigManager(TestUtils.TestLogger()).loadStrictly(yaml)
+
/** The file shipped in resources, which is what a fresh install actually reads. */
private val bundledConfig: String =
checkNotNull(javaClass.classLoader.getResourceAsStream("config.yml")) {
@@ -280,4 +283,75 @@ class ConfigManagerTest {
assertTrue(config.checkForUpdates)
assertEquals("custom.yaml", config.userSettingsFilePath)
}
+
+ @Test
+ fun `the strict reading accepts a file every setting of which is readable`() {
+ val result = loadStrictly(bundledConfig)
+
+ assertIs<ConfigLoadResult.Success>(result)
+ assertEquals(LunaticChatConfiguration(), result.configuration)
+ }
+
+ @Test
+ fun `the strict reading names the setting it could not read`() {
+ val result = loadStrictly("debug: maybe")
+
+ assertIs<ConfigLoadResult.InvalidSettings>(result)
+ assertEquals(listOf("debug"), result.fallbacks.map { it.settingKey })
+ assertTrue(
+ result.fallbacks
+ .single()
+ .reason
+ .isNotEmpty(),
+ )
+ }
+
+ @Test
+ fun `the strict reading names every unreadable setting, not just the first`() {
+ val result =
+ loadStrictly(
+ """
+ debug: maybe
+ checkForUpdates: perhaps
+ features:
+ channelChat:
+ enabled: sometimes
+ """.trimIndent(),
+ )
+
+ assertIs<ConfigLoadResult.InvalidSettings>(result)
+ assertEquals(
+ listOf("debug", "checkForUpdates", "features.channelChat.enabled").sorted(),
+ result.fallbacks.map { it.settingKey }.sorted(),
+ )
+ }
+
+ @Test
+ fun `the strict reading refuses a document holding no settings`() {
+ // The lenient reading takes this as "use the defaults"; a reload cannot tell it apart from
+ // a config.yml caught mid-write, and resetting every setting over that is unacceptable.
+ assertEquals(LunaticChatConfiguration(), load("# nothing but a comment"))
+ assertIs<ConfigLoadResult.InvalidDocument>(loadStrictly("# nothing but a comment"))
+ }
+
+ @Test
+ fun `the strict reading refuses a document that is not YAML`() {
+ assertIs<ConfigLoadResult.InvalidDocument>(loadStrictly("debug: [unclosed"))
+ }
+
+ @Test
+ fun `the strict reading refuses a document that is not a map of settings`() {
+ // No key to pin the failure on, so it must not be reported as a setting with an empty name.
+ assertIs<ConfigLoadResult.InvalidDocument>(loadStrictly("just a string"))
+ }
+
+ @Test
+ fun `the strict reading accepts a setting it does not know`() {
+ // Same forward compatibility the lenient reading has: a config.yml from a newer build must
+ // not block a reload.
+ val result = loadStrictly("aSettingFromTheFuture: true")
+
+ assertIs<ConfigLoadResult.Success>(result)
+ assertEquals(LunaticChatConfiguration(), result.configuration)
+ }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloaderTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloaderTest.kt
new file mode 100644
index 0000000..67c1557
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloaderTest.kt
@@ -0,0 +1,187 @@
+package dev.m1sk9.lunaticChat.paper.config
+
+import com.charleskorn.kaml.Yaml
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
+import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.jvm.jvmErasure
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class ConfigurationReloaderTest {
+ private val startup = TestUtils.createTestConfiguration()
+ private val holder = MessageFormatHolder(startup.messageFormat)
+ private val logger = TestUtils.TestLogger()
+
+ private fun reloaderReading(contents: () -> String) =
+ ConfigurationReloader(
+ configManager = ConfigManager(logger),
+ startupConfiguration = startup,
+ messageFormatHolder = holder,
+ logger = logger,
+ readConfigFile = contents,
+ )
+
+ /**
+ * The config.yml an operator would have after editing the startup configuration into [edit].
+ *
+ * Written out through the serializer rather than assembled by hand so the document is always a
+ * complete, well-formed file - the reload path treats a partial one as a settings change.
+ */
+ private fun fileFor(edit: LunaticChatConfiguration.() -> LunaticChatConfiguration): String =
+ Yaml.default.encodeToString(LunaticChatConfiguration.serializer(), startup.edit())
+
+ @Test
+ fun `a changed message format is applied and needs no restart`() {
+ val result =
+ reloaderReading {
+ fileFor { copy(messageFormat = messageFormat.copy(directMessageFormat = "new format {message}")) }
+ }.reload()
+
+ assertIs<ReloadResult.Applied>(result)
+ assertEquals(listOf("messageFormat.directMessageFormat"), result.applied)
+ assertEquals(emptyList(), result.restartRequired)
+ assertEquals("new format {message}", holder.current.directMessageFormat)
+ }
+
+ @Test
+ fun `a changed feature is reported as needing a restart and is not applied`() {
+ val result =
+ reloaderReading {
+ fileFor {
+ copy(features = features.copy(channelChat = features.channelChat.copy(enabled = !features.channelChat.enabled)))
+ }
+ }.reload()
+
+ assertIs<ReloadResult.Applied>(result)
+ assertEquals(emptyList(), result.applied)
+ assertEquals(listOf("features.channelChat"), result.restartRequired)
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a file identical to the running configuration changes nothing`() {
+ val result = reloaderReading { fileFor { this } }.reload()
+
+ assertIs<ReloadResult.Applied>(result)
+ assertEquals(emptyList(), result.applied)
+ assertEquals(emptyList(), result.restartRequired)
+ }
+
+ @Test
+ fun `a setting still needing a restart is reported again by a second reload`() {
+ val contents =
+ fileFor {
+ copy(
+ messageFormat = messageFormat.copy(directMessageFormat = "new format {message}"),
+ features = features.copy(channelChat = features.channelChat.copy(enabled = !features.channelChat.enabled)),
+ )
+ }
+ val reloader = reloaderReading { contents }
+
+ reloader.reload()
+ val second = reloader.reload()
+
+ assertIs<ReloadResult.Applied>(second)
+ // Already applied, so no longer new; still not in effect, so still worth reporting.
+ assertEquals(emptyList(), second.applied)
+ assertEquals(listOf("features.channelChat"), second.restartRequired)
+ }
+
+ @Test
+ fun `an unreadable setting leaves the running formats alone`() {
+ val result = reloaderReading { "debug: maybe" }.reload()
+
+ assertIs<ReloadResult.InvalidSettings>(result)
+ assertEquals(listOf("debug"), result.fallbacks.map { it.settingKey })
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a document holding no settings leaves the running formats alone`() {
+ // A config.yml read while an editor or an upload is halfway through writing it must not
+ // reset every format to its default.
+ val result = reloaderReading { "# halfway through a write" }.reload()
+
+ assertIs<ReloadResult.InvalidDocument>(result)
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a file that cannot be read leaves the running formats alone`() {
+ val result = reloaderReading { throw java.io.IOException("no such file") }.reload()
+
+ assertIs<ReloadResult.Unreadable>(result)
+ assertTrue(result.reason.contains("no such file"))
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a read failure carrying no message is still reported`() {
+ val result = reloaderReading { throw java.io.IOException() }.reload()
+
+ assertIs<ReloadResult.Unreadable>(result)
+ assertTrue(result.reason.contains("IOException"))
+ assertEquals(startup.messageFormat, holder.current)
+ }
+
+ @Test
+ fun `a reload is recorded in the server log`() {
+ reloaderReading {
+ fileFor { copy(messageFormat = messageFormat.copy(channelMessageFormat = "new channel format")) }
+ }.reload()
+
+ assertTrue(logger.infoMessages.any { it.contains("messageFormat.channelMessageFormat") })
+ }
+
+ /**
+ * The two tables below decide what a reload claims to have done, so a setting missing from both
+ * would be applied or refused without ever being named. These fail when config.yml grows a
+ * setting nobody has classified.
+ */
+ @Test
+ fun `every message format is one the reload applies`() {
+ assertEquals(
+ MessageFormatConfig::class.memberProperties.map { "messageFormat.${it.name}" }.sorted(),
+ ConfigurationReloader.APPLIED.map { it.first }.sorted(),
+ )
+ }
+
+ @Test
+ fun `every message format is a leaf`() {
+ // The applied settings are compared leaf by leaf, so a format block holding another block
+ // would have its inner values changed without being named.
+ MessageFormatConfig::class.memberProperties.forEach {
+ assertEquals(String::class, it.returnType.jvmErasure, "messageFormat.${it.name} is no longer a leaf")
+ }
+ }
+
+ @Test
+ fun `every feature block is one the reload refuses to apply`() {
+ assertEquals(
+ FeaturesConfig::class.memberProperties.map { "features.${it.name}" }.sorted(),
+ ConfigurationReloader.RESTART_REQUIRED
+ .map { it.first }
+ .filter { it.startsWith("features.") }
+ .sorted(),
+ )
+ }
+
+ @Test
+ fun `every setting outside features and messageFormat is one the reload refuses to apply`() {
+ assertEquals(
+ LunaticChatConfiguration::class
+ .memberProperties
+ .map { it.name }
+ .filterNot { it == "features" || it == "messageFormat" }
+ .sorted(),
+ ConfigurationReloader.RESTART_REQUIRED
+ .map { it.first }
+ .filterNot { it.startsWith("features.") }
+ .sorted(),
+ )
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManagerTest.kt
new file mode 100644
index 0000000..3f5776c
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManagerTest.kt
@@ -0,0 +1,116 @@
+package dev.m1sk9.lunaticChat.paper.velocity
+
+import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage
+import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageChannel
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
+import org.bukkit.entity.Player
+import org.bukkit.plugin.Plugin
+import java.util.UUID
+import java.util.logging.Logger
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class CrossServerChatManagerTest {
+ private class Fixture(
+ cacheSize: Int = 100,
+ ) {
+ val plugin = mockk<Plugin>(relaxed = true)
+ val logger = mockk<Logger>(relaxed = true)
+ val config = TestUtils.createTestConfiguration()
+ val messageFormats = TestUtils.createTestMessageFormats(config)
+ val online = TestUtils.createMockPlayer(name = "Bob")
+ val manager = CrossServerChatManager(plugin, logger, config, messageFormats, cacheSize)
+
+ init {
+ // Run scheduled main-thread tasks inline so assertions can observe their effects.
+ every { plugin.server.scheduler.runTask(plugin, any<Runnable>()) } answers {
+ secondArg<Runnable>().run()
+ mockk(relaxed = true)
+ }
+ every { plugin.server.onlinePlayers } returns listOf(online)
+ }
+
+ fun broadcastTo(online: Player): String {
+ val delivered = slot<Component>()
+ verify { online.sendMessage(capture(delivered)) }
+ return LegacyComponentSerializer.legacySection().serialize(delivered.captured)
+ }
+ }
+
+ private fun globalChat(messageId: String = "id-1") =
+ PluginMessage.GlobalChatMessage(
+ messageId = messageId,
+ serverName = "lobby",
+ playerId = UUID.randomUUID().toString(),
+ playerName = "Alice",
+ message = "hi",
+ )
+
+ @Test
+ fun `sendGlobalMessage relays the message through the plugin channel`() {
+ val f = Fixture()
+ val senderId = UUID.randomUUID()
+ val sender = TestUtils.createMockPlayer(uuid = senderId, name = "Alice")
+ every { f.plugin.server.getPlayer(senderId) } returns sender
+
+ f.manager.sendGlobalMessage(senderId, "Alice", "hi")
+
+ verify { sender.sendPluginMessage(f.plugin, PluginMessageChannel.ID, any<ByteArray>()) }
+ }
+
+ @Test
+ fun `sendGlobalMessage warns when the sender is no longer online`() {
+ val f = Fixture()
+ every { f.plugin.server.getPlayer(any<UUID>()) } returns null
+
+ f.manager.sendGlobalMessage(UUID.randomUUID(), "Alice", "hi")
+
+ verify { f.logger.warning(any<String>()) }
+ }
+
+ @Test
+ fun `an incoming message is broadcast in the configured cross-server format`() {
+ val f = Fixture()
+
+ f.manager.handleIncomingMessage(globalChat())
+
+ assertEquals("§7[§6lobby§7] §eAlice: §fhi", f.broadcastTo(f.online))
+ }
+
+ @Test
+ fun `a format replaced while the server runs applies to the next incoming message`() {
+ val f = Fixture()
+ f.messageFormats.replace(MessageFormatConfig(crossServerGlobalChatFormat = "({server}) {sender}: {message}"))
+
+ f.manager.handleIncomingMessage(globalChat())
+
+ assertEquals("(lobby) Alice: hi", f.broadcastTo(f.online))
+ }
+
+ @Test
+ fun `a message id already seen is broadcast only once`() {
+ val f = Fixture()
+ val message = globalChat(messageId = "dup")
+
+ f.manager.handleIncomingMessage(message)
+ f.manager.handleIncomingMessage(message)
+
+ verify(exactly = 1) { f.online.sendMessage(any<Component>()) }
+ }
+
+ @Test
+ fun `pruning the dedup cache still lets new messages through`() {
+ val f = Fixture(cacheSize = 1)
+
+ repeat(3) { f.manager.handleIncomingMessage(globalChat(messageId = "id-$it")) }
+
+ verify(exactly = 3) { f.online.sendMessage(any<Component>()) }
+ }
+}
diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md
index f2cfe5b..fb9f260 100644
--- a/website/src/docs/configuration.md
+++ b/website/src/docs/configuration.md
@@ -8,7 +8,23 @@ LunaticChat's configuration is managed in `plugins/LunaticChat/config.yml`. A de
## Applying Changes
-There is no reload command. Edit `config.yml` and **restart the server** to apply a change.
+`/lc reload` <Badge type="tip" text="v1.4.0~" /> re-reads `config.yml` while the server runs. It applies the [`messageFormat`](/docs/reference/message-format) settings and nothing else.
+
+Every other setting decides something that is settled once, when the plugin starts: which services exist, which commands and listeners are registered, which files are opened. Rather than pretend otherwise, the command lists the settings you changed that the running server cannot pick up, so you know a restart is still needed.
+
+```
+[LC] Reloaded config.yml, but some of the changes need a server restart to take effect.
+```
+
+The reply says only whether what you edited is in effect. Which settings moved is written to the server log.
+
+| | Applied by `/lc reload` | Needs a restart |
+|---|---|---|
+| `messageFormat.*` | Yes | — |
+| `features.*` | — | Yes |
+| `debug`, `checkForUpdates`, `language`, `userSettingsFilePath` | — | Yes |
+
+The command is available to the console and RCON as well as to players, and requires `lunaticchat.command.lc.reload` (op by default).
Boolean settings accept `true` / `false`, and also the `yes` / `no` / `on` / `off` spellings that Bukkit accepted historically, so a file written for an older release keeps working as it did.
@@ -22,6 +38,10 @@ A `config.yml` the plugin cannot use never stops the plugin from starting.
Check the server log after editing `config.yml`: a setting that quietly reverted to its default was reported there.
+`/lc reload` is stricter, because it has an option that startup does not — leaving the running configuration alone. It refuses the file rather than falling anything back to a default, naming every setting it could not read in one go, and the server keeps the configuration it already had. A file that holds no settings at all is refused for the same reason: a reload cannot tell a comments-only file apart from one caught halfway through being written.
+
+A misspelled **key** is still not detected, by either path — an unknown key is ignored so that a `config.yml` written for a newer build does not break an older one. When a reload finds nothing to apply, it says so rather than reporting success, which is the signal that a key may be misspelled.
+
## Global Settings
| Key | Type | Default | Description |
diff --git a/website/src/docs/developers/platform-paper.md b/website/src/docs/developers/platform-paper.md
index dbba4f1..2620c85 100644
--- a/website/src/docs/developers/platform-paper.md
+++ b/website/src/docs/developers/platform-paper.md
@@ -23,7 +23,7 @@ Features are assembled via manual DI, without an external DI framework. The key
The `onEnable` flow:
-1. `saveDefaultConfig()` → build `LunaticChatConfiguration` via `ConfigManager`
+1. `saveDefaultConfig()` → build `LunaticChatConfiguration` via `ConfigManager`, then create the `MessageFormatHolder` and the `ConfigurationReloader` over it
2. Initialize `HttpClient(CIO)` and `PluginCoroutineScope`
3. `ServiceInitializer.initialize()` → receive a `ServiceContainer`
4. Move services into the public properties used by commands
@@ -146,6 +146,9 @@ Channel state itself is managed by the `chat/channel` package.
- `ConfigManager` — deserializes `config.yml` into `LunaticChatConfiguration` with **KAML**, so each default lives in exactly one place: on the data class. It replaced a hand-written dotted-key mapper that repeated every default a second time, and they had already drifted — `checkForUpdates` disagreed with both `config.yml` and the data class, and the whole `messageLogging` block was documented but never read
- Failure is handled per setting, not per file: on a `YamlException` the offending key is pruned from the document and decoding is retried, so one unreadable value costs only itself. Only a document that is not YAML at all falls back to defaults wholesale, and neither case is allowed to throw out of `onEnable`
+- `ConfigManager.loadStrictly` — the reading `/lc reload` uses. It shares the pruning loop with `loadConfiguration` but reads the outcome the other way round: any setting that had to be pruned means the whole file is refused, with every unreadable setting named at once. A document holding no settings is refused too, because a reload cannot tell a comments-only file apart from one caught mid-write, and resetting every format over that is worse than doing nothing
+- `MessageFormatHolder` — the one part of the configuration that is live. Deliberately narrower than `LunaticChatConfiguration`: taking this type says the value can change under you, taking `LunaticChatConfiguration` says it was frozen at startup. A holder over the whole configuration would produce a tree matching no version of the file, since only `messageFormat` is ever swapped
+- `ConfigurationReloader` — re-reads the file and swaps the holder. It classifies every setting into applied (leaf by leaf, from `MessageFormatConfig`) or restart-required (block by block, riding on the data class `equals`), and a test over `memberProperties` fails when `config.yml` grows a setting neither table covers. The two lists are measured against different baselines: applied against the formats currently in effect, restart-required against the configuration the server actually started on. Nothing beyond `messageFormat` can be applied, because Paper registers commands only through its `COMMANDS` lifecycle event, Bukkit cannot unregister a listener, and the cache saver keeps no task handle to cancel
- `LenientBoolean` — a `Boolean` typealias with a serializer that still accepts `yes` / `no` / `on` / `off`. Bukkit read `config.yml` as YAML 1.1, where those are booleans; kaml reads YAML 1.2, where they are plain strings, and silently resetting them would have flipped `checkForUpdates: no` to its opposite default
- Feature defaults: `quickReplies=true`, `japaneseConversion=false`, `channelChat=false`, `velocityIntegration=false`
- Under `config/key`: `FeaturesConfig` / `ChannelChatFeatureConfig` / `JapaneseConversionFeatureConfig` / `VelocityIntegrationConfig` / `QuickRepliesFeatureConfig` / `MessageFormatConfig` / `ChannelMessageLoggingConfig`
diff --git a/website/src/docs/features/admin.md b/website/src/docs/features/admin.md
index 3f71dcc..531f2e0 100644
--- a/website/src/docs/features/admin.md
+++ b/website/src/docs/features/admin.md
@@ -22,6 +22,26 @@ Displayed information:
- Configuration values (debug mode, update checking, language)
- Links to GitHub, Modrinth, and documentation
+## Configuration Reload (`/lc reload`) <Badge type="tip" text="v1.4.0~" />
+
+Re-read `config.yml` without restarting the server.
+
+```
+/lc reload
+```
+
+Only the [`messageFormat`](/docs/reference/message-format) settings are applied. Everything else in `config.yml` decides something the plugin settles at startup, so the command lists the changed settings that still need a restart instead of pretending they took effect.
+
+```
+[LC] Reloaded config.yml, but some of the changes need a server restart to take effect.
+```
+
+The reply says only whether what you edited is in effect. Which settings moved is written to the server log.
+
+- **Permission**: `lunaticchat.command.lc.reload` (default: op). Usable from the console and RCON
+- A `config.yml` the plugin cannot read in full is refused: every unreadable setting is named at once and the running configuration is kept. This is stricter than startup, which falls a single unreadable setting back to its default so the server can come up
+- Every reload is written to the server log, so a format change can be dated later. The file is read off the main thread, and an RCON session closes before the reply reaches it, so RCON callers read the outcome from the log rather than from the command output
+
## Spy Mode
Players with the `lunaticchat.spy` permission (default: op) can view both the direct messages and the channel messages sent on the server.
diff --git a/website/src/docs/getting-started.md b/website/src/docs/getting-started.md
index 104e00d..186867c 100644
--- a/website/src/docs/getting-started.md
+++ b/website/src/docs/getting-started.md
@@ -34,7 +34,7 @@ Use `LunaticChat-<version>.jar` for Paper / Folia servers and `LunaticChat-<vers
1. Place the downloaded `LunaticChat-<version>.jar` into the server's `plugins/` directory
2. Start (or restart) the server
3. `plugins/LunaticChat/config.yml` will be generated automatically
-4. Modify the [configuration](/docs/configuration) as needed and restart the server
+4. Modify the [configuration](/docs/configuration) as needed, then run `/lc reload` or restart the server
### Velocity
diff --git a/website/src/docs/permissions.md b/website/src/docs/permissions.md
index caada8b..d55d895 100644
--- a/website/src/docs/permissions.md
+++ b/website/src/docs/permissions.md
@@ -48,3 +48,4 @@ The following permissions are granted to OPs only by default.
| `lunaticchat.noticeupdate` | op | Receive update notifications |
| `lunaticchat.channelbypass` | op | Bypass channel restrictions (kick/ban protection, force deletion) |
| `lunaticchat.command.lcv.status` | op | Use the `/lcv status` command |
+| `lunaticchat.command.lc.reload` | op | Use the `/lc reload` command |
diff --git a/website/src/docs/reference/commands.md b/website/src/docs/reference/commands.md
index 28627bc..50bcbe9 100644
--- a/website/src/docs/reference/commands.md
+++ b/website/src/docs/reference/commands.md
@@ -36,6 +36,14 @@ Displays the plugin version, health, enabled features, and configuration values.
- **Aliases**: `st`
- **Permission**: `lunaticchat.command.lc.status`
+### `/lc reload` <Badge type="tip" text="v1.4.0~" />
+
+Re-reads `config.yml` and applies the `messageFormat` settings, then reports which of the remaining changes still need a restart. See [Applying Changes](/docs/configuration#applying-changes).
+
+- **Permission**: `lunaticchat.command.lc.reload` (op by default)
+- **Usable from the console and RCON**
+- A `config.yml` that cannot be read in full is refused, and the running configuration is kept
+
### `/lc settings [key] [on|off]`
Views or changes your personal settings. Without arguments, displays the settings list.
diff --git a/website/src/docs/reference/message-format.md b/website/src/docs/reference/message-format.md
index 2669adb..68fee84 100644
--- a/website/src/docs/reference/message-format.md
+++ b/website/src/docs/reference/message-format.md
@@ -6,6 +6,8 @@ layout: doc
You can customize the display format of chat messages in the `messageFormat` section of `config.yml`.
+These are the only settings `/lc reload` <Badge type="tip" text="v1.4.0~" /> applies without a restart; every other section of `config.yml` is fixed when the server starts. See [Applying Changes](/docs/configuration#applying-changes).
+
## Placeholders
| Placeholder | Description | Available formats |
diff --git a/website/src/ja/docs/configuration.md b/website/src/ja/docs/configuration.md
index 3324940..a2ae86f 100644
--- a/website/src/ja/docs/configuration.md
+++ b/website/src/ja/docs/configuration.md
@@ -8,7 +8,23 @@ LunaticChat の設定は `plugins/LunaticChat/config.yml` で管理されますï
## 設定の反映
-リロードコマンドはありません.`config.yml` を編集したら**サーバーを再起動**してください.
+`/lc reload` <Badge type="tip" text="v1.4.0~" /> はサーバーの稼働中に `config.yml` を読み直します.反映されるのは [`messageFormat`](/ja/docs/reference/message-format) の設定のみです.
+
+ほかの設定は,どのサービスを構築するか・どのコマンドとリスナーを登録するか・どのファイルを開くかといった,プラグインの起動時に一度だけ決まる事柄を左右します.反映されたように見せかけることはせず,変更したもののうち稼働中のサーバーが取り込めない設定を一覧で表示するので,再起動が必要かどうかが分かります.
+
+```
+[LC] config.yml を再読み込みしましたが,一部の変更の適用にはサーバーの再起動が必要です.
+```
+
+応答は,編集した内容が反映されたかどうかだけを伝えます.どの設定が動いたかはサーバーログに記録されます.
+
+| | `/lc reload` で反映 | 再起動が必要 |
+|---|---|---|
+| `messageFormat.*` | はい | — |
+| `features.*` | — | はい |
+| `debug`, `checkForUpdates`, `language`, `userSettingsFilePath` | — | はい |
+
+このコマンドはプレイヤーのほかコンソール・RCON からも実行できます.`lunaticchat.command.lc.reload` (デフォルト: op) が必要です.
真偽値の設定は `true` / `false` のほか,Bukkit が従来受け付けていた `yes` / `no` / `on` / `off` の表記も使用できます.古いリリース向けに書かれたファイルもそのまま動作します.
@@ -22,6 +38,10 @@ LunaticChat の設定は `plugins/LunaticChat/config.yml` で管理されますï
`config.yml` を編集したあとはサーバーログを確認してください.デフォルトに戻された設定があれば,そこに報告されています.
+`/lc reload` はこれよりも厳格です.起動時にはない選択肢 — 稼働中の設定をそのまま使い続ける — を取れるためです.デフォルトへのフォールバックは行わずファイルごと拒否し,読み取れなかった設定をすべてまとめて表示します.サーバーは元の設定を保持したままです.設定を 1 つも含まないファイルも同じ理由で拒否されます.コメントのみのファイルと,書き込み途中のファイルを見分けられないためです.
+
+**キー名**のタイポは,どちらの経路でも検出されません.新しいビルド向けに書かれた `config.yml` が古いビルドで壊れないよう,未知のキーは無視されるためです.リロードで反映対象が 1 つも見つからなかった場合は,成功としてではなく「変更なし」として報告されます.これがキー名を疑う手がかりになります.
+
## グローバル設定
| キー | 型 | デフォルト | 説明 |
diff --git a/website/src/ja/docs/developers/platform-paper.md b/website/src/ja/docs/developers/platform-paper.md
index 26a9ad7..e291bcf 100644
--- a/website/src/ja/docs/developers/platform-paper.md
+++ b/website/src/ja/docs/developers/platform-paper.md
@@ -23,7 +23,7 @@ paper 側は「Bukkit / Folia という現実」を吸収するアダプタとã�
`onEnable` の流れは次の通りです.
-1. `saveDefaultConfig()` → `ConfigManager` で `LunaticChatConfiguration` を生成
+1. `saveDefaultConfig()` → `ConfigManager` で `LunaticChatConfiguration` を生成し,`MessageFormatHolder` と `ConfigurationReloader` を構築
2. `HttpClient(CIO)` と `PluginCoroutineScope` を初期化
3. `ServiceInitializer.initialize()` → `ServiceContainer` を受け取る
4. コマンドから使う公開プロパティへサービスを移し替え
@@ -146,6 +146,9 @@ config フラグ
- `ConfigManager` — `config.yml` を **KAML** で `LunaticChatConfiguration` にデシリアライズする.デフォルト値の定義箇所をデータクラス 1 箇所に限定するためで,以前のドット記法の手組みマッパーは同じデフォルトを二重に持っており,実際に乖離していた (`checkForUpdates` が `config.yml` とデータクラスの双方と食い違い,`messageLogging` ブロックはドキュメント化されていながら一度も読まれていなかった)
- 失敗はファイル単位ではなく設定単位で処理する.`YamlException` が出た場合は該当キーをドキュメントから取り除いてデコードを再試行するため,読めない値 1 つの影響はその値だけに留まる.全体をデフォルトに落とすのは「YAML として成立していない」場合だけで,いずれのケースも `onEnable` の外へ例外を投げない
+- `ConfigManager.loadStrictly` — `/lc reload` が使う読み取り経路.pruning ループは `loadConfiguration` と共有するが,結果の解釈が逆で,1 つでも pruning が発生した時点でファイル全体を拒否し,読み取れなかった設定をまとめて報告する.設定を含まないドキュメントも拒否する.コメントのみのファイルと書き込み途中のファイルを見分けられず,それを理由に全フォーマットをリセットするのは何もしないより悪いため
+- `MessageFormatHolder` — 設定のうち唯一実行時に差し替わる部分.`LunaticChatConfiguration` より意図的に狭く,この型を受け取ることが「値が変わりうる」,`LunaticChatConfiguration` を受け取ることが「起動時に凍結された」というシグナルになる.設定全体を holder に入れると,差し替わるのは `messageFormat` だけであるため,どのバージョンのファイルとも一致しないツリーができてしまう
+- `ConfigurationReloader` — ファイルを読み直して holder を差し替える.各設定を「適用済み」(`MessageFormatConfig` から leaf 単位) と「再起動が必要」(データクラスの `equals` に乗せた block 単位) に分類し,`memberProperties` を走査するテストが,どちらの表にも載っていない設定が `config.yml` に増えた時点で失敗する.2 つのリストは比較基準が異なり,適用済みは現在有効なフォーマットとの差分,再起動が必要は実際にサーバーが起動した設定との差分である.`messageFormat` 以外を適用できないのは,Paper がコマンドを `COMMANDS` ライフサイクルイベントでしか登録できず,Bukkit にリスナーの解除手段がなく,キャッシュ保存タスクがキャンセル用のハンドルを保持していないため
- `LenientBoolean` — `yes` / `no` / `on` / `off` も受け付けるシリアライザ付きの `Boolean` typealias.Bukkit は `config.yml` を YAML 1.1 として読んでいたためこれらは真偽値だったが,kaml が読む YAML 1.2 では単なる文字列であり,黙ってリセットすると `checkForUpdates: no` がデフォルトの逆の値に反転してしまう
- 機能デフォルト: `quickReplies=true`, `japaneseConversion=false`, `channelChat=false`, `velocityIntegration=false`
- `config/key` 以下に `FeaturesConfig` / `ChannelChatFeatureConfig` / `JapaneseConversionFeatureConfig` / `VelocityIntegrationConfig` / `QuickRepliesFeatureConfig` / `MessageFormatConfig` / `ChannelMessageLoggingConfig`
diff --git a/website/src/ja/docs/features/admin.md b/website/src/ja/docs/features/admin.md
index a29be09..f6c86b8 100644
--- a/website/src/ja/docs/features/admin.md
+++ b/website/src/ja/docs/features/admin.md
@@ -22,6 +22,26 @@ layout: doc
- 設定値 (デバッグモード,アップデート確認,言語)
- GitHub,Modrinth,ドキュメントへのリンク
+## 設定のリロード (`/lc reload`) <Badge type="tip" text="v1.4.0~" />
+
+サーバーを再起動せずに `config.yml` を読み直します.
+
+```
+/lc reload
+```
+
+反映されるのは [`messageFormat`](/ja/docs/reference/message-format) の設定のみです.`config.yml` のほかの設定はプラグインが起動時に確定させる事柄を左右するため,反映されたように見せかけることはせず,再起動が必要な変更を一覧で表示します.
+
+```
+[LC] config.yml を再読み込みしましたが,一部の変更の適用にはサーバーの再起動が必要です.
+```
+
+応答は,編集した内容が反映されたかどうかだけを伝えます.どの設定が動いたかはサーバーログに記録されます.
+
+- **パーミッション**: `lunaticchat.command.lc.reload` (デフォルト: op).コンソール・RCON からも実行可能
+- `config.yml` を完全に読み取れない場合は拒否されます.読み取れなかった設定をすべてまとめて表示し,稼働中の設定はそのまま維持されます.読み取れない設定を個別にデフォルトへフォールバックさせて起動を続行する起動時よりも厳格です
+- リロードはサーバーログにも記録されるため,フォーマットを変更した時期をあとから追跡できます.ファイルの読み取りはメインスレッド外で行われ,RCON セッションは応答が届く前に切断されるため,RCON から実行した場合の結果はコマンド出力ではなくログで確認します
+
## スパイモード
`lunaticchat.spy` パーミッション (デフォルト: op) を持つプレイヤーは,サーバー上で送信されるダイレクトメッセージとチャンネルメッセージの両方を閲覧できます.
diff --git a/website/src/ja/docs/getting-started.md b/website/src/ja/docs/getting-started.md
index 91f4e89..50bf812 100644
--- a/website/src/ja/docs/getting-started.md
+++ b/website/src/ja/docs/getting-started.md
@@ -34,7 +34,7 @@ Paper / Folia サーバーには `LunaticChat-<version>.jar` を,Velocity プã
1. ダウンロードした `LunaticChat-<version>.jar` をサーバーの `plugins/` ディレクトリに配置します
2. サーバーを起動 (または再起動) します
3. `plugins/LunaticChat/config.yml` が自動生成されます
-4. 必要に応じて[設定](/ja/docs/configuration)を変更し,サーバーを再起動します
+4. 必要に応じて[設定](/ja/docs/configuration)を変更し,`/lc reload` を実行するかサーバーを再起動します
### Velocity
diff --git a/website/src/ja/docs/permissions.md b/website/src/ja/docs/permissions.md
index b2726dd..05ca9cb 100644
--- a/website/src/ja/docs/permissions.md
+++ b/website/src/ja/docs/permissions.md
@@ -48,3 +48,4 @@ LunaticChat のすべてのパーミッションノードの一覧です.
| `lunaticchat.noticeupdate` | op | アップデート通知の受信 |
| `lunaticchat.channelbypass` | op | チャンネル制限のバイパス(キック・BAN 保護,強制削除) |
| `lunaticchat.command.lcv.status` | op | `/lcv status` コマンドの使用 |
+| `lunaticchat.command.lc.reload` | op | `/lc reload` コマンドの使用 |
diff --git a/website/src/ja/docs/reference/commands.md b/website/src/ja/docs/reference/commands.md
index 8102a75..6d34f1c 100644
--- a/website/src/ja/docs/reference/commands.md
+++ b/website/src/ja/docs/reference/commands.md
@@ -36,6 +36,14 @@ LunaticChat で使用できるすべてのコマンドのリファレンスでã�
- **エイリアス**: `st`
- **パーミッション**: `lunaticchat.command.lc.status`
+### `/lc reload` <Badge type="tip" text="v1.4.0~" />
+
+`config.yml` を読み直して `messageFormat` の設定を反映し,残りの変更のうち再起動が必要なものを報告します.[設定の反映](/ja/docs/configuration#設定の反映)を参照してください.
+
+- **パーミッション**: `lunaticchat.command.lc.reload` (デフォルト: op)
+- **コンソール・RCON からも実行可能**
+- `config.yml` を完全に読み取れない場合は拒否され,稼働中の設定がそのまま維持されます
+
### `/lc settings [key] [on|off]`
プレイヤー個人の設定を確認・変更します.引数なしで設定一覧を表示します.
diff --git a/website/src/ja/docs/reference/message-format.md b/website/src/ja/docs/reference/message-format.md
index 7ae42e9..3eddd1a 100644
--- a/website/src/ja/docs/reference/message-format.md
+++ b/website/src/ja/docs/reference/message-format.md
@@ -6,6 +6,8 @@ layout: doc
`config.yml` の `messageFormat` セクションで,チャットメッセージの表示形式をカスタマイズできます.
+`/lc reload` <Badge type="tip" text="v1.4.0~" /> が再起動なしで反映できるのはこれらの設定だけです.`config.yml` のほかのセクションはサーバー起動時に固定されます.[設定の反映](/ja/docs/configuration#設定の反映)を参照してください.
+
## プレースホルダー
| プレースホルダー | 説明 | 使用可能なフォーマット |