summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-10 08:31:20 +0900
committerSho Sakuma <me@m1sk9.dev>2026-08-10 08:31:20 +0900
commitf08d4d8f0557581d93063cd9f6e750ab3ac9cc17 (patch)
tree52c3bc1fbd6c0012edd3b1239f58d262e1a4e23d
parentf52d13819e431af39e5a8c9be178307261a12d19 (diff)
downloadLunaticChat-f08d4d8f0557581d93063cd9f6e750ab3ac9cc17.tar.gz
LunaticChat-f08d4d8f0557581d93063cd9f6e750ab3ac9cc17.tar.bz2
LunaticChat-f08d4d8f0557581d93063cd9f6e750ab3ac9cc17.zip
feat(paper): add /lc reload to apply message formats without a restart
Changing a message format meant taking the server down, because config.yml was read once in onEnable and every consumer held the value it was built with. Operators tune these formats far more often than anything else in the file, and a restart is a disproportionate price for a colour code. Only messageFormat is applied. The rest of config.yml settles things once, at startup - which services exist, which commands and listeners are registered, which files are opened - and none of that can be redone while the server runs: Paper registers commands only through its COMMANDS lifecycle event, Bukkit cannot unregister a listener, and the conversion cache saver keeps no task handle to cancel. Rebuilding the services would leave the old ones still wired up, so a changed setting the server cannot pick up is reported back as needing a restart rather than silently ignored. MessageFormatHolder is deliberately narrower than LunaticChatConfiguration: taking it says the value is live, taking the configuration says it was frozen at startup. A holder over the whole tree would hold a configuration matching no version of the file, since only messageFormat is ever swapped. Reload reads config.yml more strictly than startup does, because it has an option startup does not - leaving the running configuration alone. Startup falls a single unreadable setting back to its default so the server can come up; reload refuses the file, names every unreadable setting at once, and keeps what the server already had. A document holding no settings is refused for the same reason: it cannot be told apart from a config.yml caught halfway through being written, and resetting every format over that is worse than doing nothing. Closes #266 Co-Authored-By: Claude <noreply@anthropic.com>
-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.kt140
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt102
-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.yml11
-rw-r--r--platform-paper/src/main/resources/languages/ja.yml11
-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/DirectMessageHandlerTest.kt7
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommandTest.kt114
-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.kt178
-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
37 files changed, 977 insertions, 42 deletions
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..ca8f512
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommand.kt
@@ -0,0 +1,140 @@
+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) {
+ is ReloadResult.Applied -> reportApplied(ctx, result)
+
+ 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")
+ }
+ }
+
+ private fun reportApplied(
+ ctx: CommandContext,
+ result: ReloadResult.Applied,
+ ): CommandResult {
+ if (result.applied.isEmpty() && result.restartRequired.isEmpty()) {
+ return ok("reload.noChanges")
+ }
+
+ // Sent here rather than returned, so the headline lands before the detail lines instead of
+ // after them, which is where handleResult would put it.
+ ctx.reply(MessageFormatter.format(languageManager.getMessage("reload.success")))
+ if (result.applied.isNotEmpty()) {
+ ctx.reply(
+ MessageFormatter.format(
+ languageManager.getMessage("reload.applied", mapOf("keys" to result.applied.joinToString(", "))),
+ ),
+ )
+ }
+ if (result.restartRequired.isNotEmpty()) {
+ ctx.reply(
+ MessageFormatter.format(
+ languageManager.getMessage("reload.restartRequired", mapOf("keys" to result.restartRequired.joinToString(", "))),
+ ),
+ )
+ }
+ return CommandResult.Success
+ }
+
+ /**
+ * 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..2aee026 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,96 @@ 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 worse 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)
+ 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..428473a 100644
--- a/platform-paper/src/main/resources/languages/en.yml
+++ b/platform-paper/src/main/resources/languages/en.yml
@@ -65,6 +65,17 @@ status:
settings:
availableValues: "Available settings value: {values}"
+reload:
+ success: "Reloaded config.yml."
+ applied: "Applied: {keys}"
+ restartRequired: "Restart the server to apply: {keys}"
+ noChanges: "config.yml holds no change that can be applied without a restart."
+ inProgress: "A reload is already running."
+ invalidSettings: "Reload aborted. These settings in config.yml could not be read:"
+ invalidDocument: "Reload aborted. config.yml could not be read as a settings document:"
+ unreadable: "Reload aborted. config.yml could not be read:"
+ 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..c2775cc 100644
--- a/platform-paper/src/main/resources/languages/ja.yml
+++ b/platform-paper/src/main/resources/languages/ja.yml
@@ -65,6 +65,17 @@ status:
settings:
availableValues: "使用可能な設定値: {values}"
+reload:
+ success: "config.yml を再読み込みしました."
+ applied: "適用しました: {keys}"
+ restartRequired: "適用するにはサーバーの再起動が必要です: {keys}"
+ noChanges: "config.yml に,再起動なしで適用できる変更はありませんでした."
+ inProgress: "再読み込みはすでに実行中です."
+ invalidSettings: "再読み込みを中止しました.config.yml の次の設定を読み取れません:"
+ invalidDocument: "再読み込みを中止しました.config.yml を設定ファイルとして読み取れません:"
+ unreadable: "再読み込みを中止しました.config.yml を読み取れません:"
+ 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/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..1cb55d4
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/ReloadCommandTest.kt
@@ -0,0 +1,114 @@
+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 `a reload the console runs succeeds without a player`() {
+ val ctx = consoleContext()
+
+ val result = commandReading { "messageFormat:\n directMessageFormat: \"new {message}\"" }.execute(ctx)
+
+ assertIs<CommandResult.Success>(result)
+ assertEquals("new {message}", holder.current.directMessageFormat)
+ verify(atLeast = 1) { ctx.reply(any()) }
+ }
+
+ @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..125a991
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigurationReloaderTest.kt
@@ -0,0 +1,178 @@
+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 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/website/src/docs/configuration.md b/website/src/docs/configuration.md
index f2cfe5b..86a4d1a 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.
+[LC] Applied: messageFormat.directMessageFormat
+[LC] Restart the server to apply: features.channelChat
+```
+
+| | 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..dc49e56 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.
+[LC] Applied: messageFormat.directMessageFormat
+[LC] Restart the server to apply: features.channelChat
+```
+
+- **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..69f4976 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] 適用しました: messageFormat.directMessageFormat
+[LC] 適用するにはサーバーの再起動が必要です: features.channelChat
+```
+
+| | `/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..61843b0 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 を再読み込みしました.
+[LC] 適用しました: messageFormat.directMessageFormat
+[LC] 適用するにはサーバーの再起動が必要です: features.channelChat
+```
+
+- **パーミッション**: `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#設定の反映)を参照してください.
+
## プレースホルダー
| プレースホルダー | 説明 | 使用可能なフォーマット |