summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 02:47:26 +0900
committerGitHub <noreply@github.com>2026-08-05 02:47:26 +0900
commit7c544224291e8ed7e23dcf97ea3cc6f42588c338 (patch)
treed1c9212600bc111031ac8a8a16b8f2fac0e08d6d
parentf4bff879ac2f4323011d41e4f7ee85a9695515a5 (diff)
parentc29d1621b26f9c1dfbf0a26f4433996060babf10 (diff)
downloadLunaticChat-7c544224291e8ed7e23dcf97ea3cc6f42588c338.tar.gz
LunaticChat-7c544224291e8ed7e23dcf97ea3cc6f42588c338.tar.bz2
LunaticChat-7c544224291e8ed7e23dcf97ea3cc6f42588c338.zip
Merge pull request #262 from m1sk9/refactor/config-and-module-boundaries
refactor: put config, protocol and module boundaries where they belong
-rw-r--r--engine/build.gradle.kts12
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResult.kt9
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt19
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt8
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResultTest.kt9
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt2
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt16
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/settings/SettingsDataClassesTest.kt25
-rw-r--r--platform-paper/build.gradle.kts5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt18
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt10
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommandBase.kt8
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelCreateCommand.kt5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt201
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt53
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt19
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt11
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt38
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt3
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt6
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt10
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt (renamed from engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/CacheData.kt)2
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt1
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt (renamed from engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/GoogleIMEClient.kt)2
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt (renamed from engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/KanaConverter.kt)2
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt2
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt28
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt2
-rw-r--r--platform-paper/src/main/resources/config.yml2
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt7
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManagerTest.kt426
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheDataTest.kt31
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverterTest.kt (renamed from engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/converter/KanaConverterTest.kt)2
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt2
-rw-r--r--platform-velocity/build.gradle.kts2
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt2
-rw-r--r--website/src/docs/configuration.md1
-rw-r--r--website/src/docs/features/japanese-conversion.md1
-rw-r--r--website/src/ja/docs/configuration.md1
-rw-r--r--website/src/ja/docs/features/japanese-conversion.md1
42 files changed, 584 insertions, 426 deletions
diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts
index bcb7eb6..5782890 100644
--- a/engine/build.gradle.kts
+++ b/engine/build.gradle.kts
@@ -4,15 +4,7 @@ plugins {
}
dependencies {
- // Core dependencies (exposed to platform modules via api())
+ // Exposed to platform modules via api(): the plugin messaging protocol is built on it, so
+ // both platforms need it on their compile and runtime classpath.
api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
- api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
- api("io.ktor:ktor-client-core:3.5.2")
- api("io.ktor:ktor-client-cio:3.5.2")
-
- // Adventure API (provided by platform implementations)
- // Matches what every supported platform ships: Paper 26.2 and Velocity 4.x both bundle 5.2.0.
- compileOnly("net.kyori:adventure-api:5.2.0")
-
- testImplementation("net.kyori:adventure-api:5.2.0")
}
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResult.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResult.kt
index 9c9d882..c9d7771 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResult.kt
+++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResult.kt
@@ -1,10 +1,11 @@
package dev.m1sk9.lunaticChat.engine.command
-import net.kyori.adventure.text.Component
-
/**
* Represents the result of a command execution.
* Uses Kotlin sealed classes for type-safe result handling.
+ *
+ * Messages travel as plain text; turning them into styled output is the platform's job, so this
+ * stays free of any Minecraft or Adventure type.
*/
sealed class CommandResult {
/** Command executed successfully */
@@ -12,12 +13,12 @@ sealed class CommandResult {
/** Command executed successfully with a message to display */
data class SuccessWithMessage(
- val message: Component,
+ val message: String,
) : CommandResult()
/** Command failed with an error message */
data class Failure(
- val message: Component,
+ val message: String,
) : CommandResult()
/** Command failed due to invalid usage */
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt
index 1ba5c25..689a57c 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt
+++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt
@@ -113,7 +113,9 @@ sealed interface PluginMessage {
* @property senderId Sender UUID as string (used to locate the sender to notify)
* @property targetName Target player name that was requested
* @property targetServerName Target server name that was requested
- * @property reason Failure reason: [Reason.TARGET_OFFLINE] or [Reason.SERVER_NOT_FOUND]
+ * @property reason Why delivery failed. Defaults to [Reason.TARGET_OFFLINE] so that a reason
+ * added by a newer peer degrades to the generic failure rather than failing to decode -
+ * [PluginMessageCodec] enables coerceInputValues for exactly this.
*/
@Serializable
data class DirectMessageError(
@@ -121,11 +123,18 @@ sealed interface PluginMessage {
val senderId: String,
val targetName: String,
val targetServerName: String,
- val reason: String,
+ val reason: Reason = Reason.TARGET_OFFLINE,
) : PluginMessage {
- object Reason {
- const val TARGET_OFFLINE = "TARGET_OFFLINE"
- const val SERVER_NOT_FOUND = "SERVER_NOT_FOUND"
+ /**
+ * Why a cross-server direct message could not be delivered.
+ *
+ * An enum rather than string constants so that adding a case forces every reader to
+ * decide what to show for it, instead of silently reporting the existing default.
+ */
+ @Serializable
+ enum class Reason {
+ TARGET_OFFLINE,
+ SERVER_NOT_FOUND,
}
}
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt
index 14d1f82..f3b52c6 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt
+++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt
@@ -13,7 +13,13 @@ import java.io.DataOutputStream
* Format: [subChannel: UTF][messageJson: UTF]
*/
object PluginMessageCodec {
- private val json = Json { ignoreUnknownKeys = true }
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ // An enum value this build does not know falls back to the property's default rather
+ // than failing the whole message, matching how unknown fields are treated.
+ coerceInputValues = true
+ }
/**
* Sub-channel names
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResultTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResultTest.kt
index b80f116..a9033d0 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResultTest.kt
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/command/CommandResultTest.kt
@@ -1,6 +1,5 @@
package dev.m1sk9.lunaticChat.engine.command
-import net.kyori.adventure.text.Component
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
@@ -13,13 +12,13 @@ class CommandResultTest {
@Test
fun `SuccessWithMessage toBrigadierResult should return 1`() {
- val result = CommandResult.SuccessWithMessage(Component.text("ok"))
+ val result = CommandResult.SuccessWithMessage("ok")
assertEquals(1, result.toBrigadierResult())
}
@Test
fun `Failure toBrigadierResult should return 0`() {
- val result = CommandResult.Failure(Component.text("error"))
+ val result = CommandResult.Failure("error")
assertEquals(0, result.toBrigadierResult())
}
@@ -31,7 +30,7 @@ class CommandResultTest {
@Test
fun `SuccessWithMessage should preserve message`() {
- val message = Component.text("Test message")
+ val message = "Test message"
val result = CommandResult.SuccessWithMessage(message)
assertIs<CommandResult.SuccessWithMessage>(result)
assertEquals(message, result.message)
@@ -39,7 +38,7 @@ class CommandResultTest {
@Test
fun `Failure should preserve message`() {
- val message = Component.text("Error message")
+ val message = "Error message"
val result = CommandResult.Failure(message)
assertIs<CommandResult.Failure>(result)
assertEquals(message, result.message)
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt
index 0eb56c1..e1c65ba 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt
@@ -291,7 +291,7 @@ class PluginMessageCodecTest {
PluginMessage.StatusResponse("1.0.0", "1.0.0", true),
PluginMessage.GlobalChatMessage("id", "srv", "pid", "name", "msg", 0L),
PluginMessage.DirectMessageRelay("id", "src", "sid", "sname", "tsrv", "tname", "msg", 0L),
- PluginMessage.DirectMessageError("id", "sid", "tname", "tsrv", "TARGET_OFFLINE"),
+ PluginMessage.DirectMessageError("id", "sid", "tname", "tsrv", PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE),
PluginMessage.PresenceSnapshot(listOf(PresenceEntry("p", "s")), 0L),
PluginMessage.PresenceRequest,
)
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt
index c9bc6c0..e70edda 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt
@@ -174,7 +174,7 @@ class ProtocolBackwardCompatibilityTest {
assertEquals("00000005-0000-0000-0000-000000000000", decoded.senderId)
assertEquals("Ghost", decoded.targetName)
assertEquals("lobby", decoded.targetServerName)
- assertEquals("TARGET_OFFLINE", decoded.reason)
+ assertEquals(PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE, decoded.reason)
}
@Test
@@ -196,4 +196,18 @@ class ProtocolBackwardCompatibilityTest {
assertIs<PluginMessage.PresenceRequest>(decoded)
}
+
+ @Test
+ fun `a failure reason this build does not know decodes to the generic failure`() {
+ val fromNewerPeer =
+ """{"messageId":"dm-789","senderId":"00000006-0000-0000-0000-000000000000",""" +
+ """"targetName":"Ghost","targetServerName":"lobby","reason":"RATE_LIMITED"}"""
+
+ val decoded = PluginMessageCodec.decode(buildRawMessage("direct_message_error", fromNewerPeer))
+
+ // A proxy that learns a new reason must not make this build drop the whole message.
+ assertIs<PluginMessage.DirectMessageError>(decoded)
+ assertEquals("dm-789", decoded.messageId)
+ assertEquals(PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE, decoded.reason)
+ }
}
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/settings/SettingsDataClassesTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/settings/SettingsDataClassesTest.kt
index a7cc2ee..bd337ff 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/settings/SettingsDataClassesTest.kt
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/settings/SettingsDataClassesTest.kt
@@ -1,6 +1,5 @@
package dev.m1sk9.lunaticChat.engine.settings
-import dev.m1sk9.lunaticChat.engine.converter.CacheData
import kotlinx.serialization.json.Json
import java.util.UUID
import kotlin.test.Test
@@ -104,28 +103,4 @@ class SettingsDataClassesTest {
val serialized = json.encodeToString(PlayerSettingsData.serializer(), data)
assertTrue(serialized.contains(testUUID.toString()))
}
-
- // --- CacheData ---
-
- @Test
- fun `CacheData should store version and entries`() {
- val data = CacheData(version = "1.0", entries = mapOf("hello" to "こんにちは"))
- assertEquals("1.0", data.version)
- assertEquals("こんにちは", data.entries["hello"])
- }
-
- @Test
- fun `CacheData serialization round-trip should preserve all fields`() {
- val original =
- CacheData(
- version = "2.0",
- entries = mapOf("hello" to "こんにちは", "world" to "世界"),
- )
-
- val serialized = json.encodeToString(CacheData.serializer(), original)
- val deserialized = json.decodeFromString(CacheData.serializer(), serialized)
-
- assertEquals(original.version, deserialized.version)
- assertEquals(original.entries, deserialized.entries)
- }
}
diff --git a/platform-paper/build.gradle.kts b/platform-paper/build.gradle.kts
index a846ed9..5c2db6f 100644
--- a/platform-paper/build.gradle.kts
+++ b/platform-paper/build.gradle.kts
@@ -15,11 +15,14 @@ repositories {
}
dependencies {
- // Engine module (provides serialization, coroutines, ktor)
+ // Engine module (provides serialization)
api(project(":engine"))
// Paper-specific dependencies
compileOnly("io.papermc.paper:paper-api:26.2.build.92-stable")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
+ implementation("io.ktor:ktor-client-core:3.5.2") // Google IME client, update check
+ implementation("io.ktor:ktor-client-cio:3.5.2")
implementation("com.charleskorn.kaml:kaml:0.104.0") // YAML configuration
implementation("org.jetbrains.kotlin:kotlin-reflect:2.4.10") // Annotation processing
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 f39e5d4..3f9cb76 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
@@ -58,8 +58,7 @@ class LunaticChat :
override fun onEnable() {
saveDefaultConfig()
- val configManager = ConfigManager()
- configuration = configManager.loadConfiguration(config)
+ configuration = ConfigManager(logger).loadConfiguration(readConfigFile())
if (configuration.debug) {
logger.warning("LunaticChat is running in debug mode.")
@@ -103,6 +102,21 @@ class LunaticChat :
}
/**
+ * Reads config.yml, or an empty document when it cannot be read.
+ *
+ * [saveDefaultConfig] only logs when it fails to write the file, so the read can still find
+ * 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}")
+ ""
+ }
+ }
+
+ /**
* Registers all commands based on enabled features.
*/
private fun registerCommands() {
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 5ec2e0a..bbfaab2 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
@@ -1,6 +1,5 @@
package dev.m1sk9.lunaticChat.paper
-import dev.m1sk9.lunaticChat.engine.converter.GoogleIMEClient
import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager
import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMembershipManager
import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelMessageLogger
@@ -10,6 +9,7 @@ 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.converter.ConversionCache
+import dev.m1sk9.lunaticChat.paper.converter.GoogleIMEClient
import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
@@ -190,8 +190,8 @@ class ServiceInitializer(
// Initialize conversion cache
val cache =
ConversionCache(
- cacheFile = plugin.dataFolder.resolve(configuration.features.japaneseConversion.cacheFilePath).toPath(),
- maxEntries = configuration.features.japaneseConversion.cacheMaxEntries,
+ cacheFile = plugin.dataFolder.resolve(configuration.features.japaneseConversion.cache.filePath).toPath(),
+ maxEntries = configuration.features.japaneseConversion.cache.maxEntries,
logger = logger,
)
cache.loadFromDisk()
@@ -199,7 +199,7 @@ class ServiceInitializer(
// Initialize Google IME API client
val apiClient =
GoogleIMEClient(
- timeout = configuration.features.japaneseConversion.apiTimeout.milliseconds,
+ timeout = configuration.features.japaneseConversion.api.timeout.milliseconds,
httpClient = httpClient.value,
)
@@ -428,7 +428,7 @@ class ServiceInitializer(
// would both be rejected by runAtFixedRate and leave the cache unsaved until the
// server stopped. Fall back to the documented default rather than to one second,
// which would rewrite the whole cache file every tick anyone chatted.
- val configuredInterval = configuration.features.japaneseConversion.cacheSaveIntervalSeconds
+ val configuredInterval = configuration.features.japaneseConversion.cache.saveIntervalSeconds
val intervalSeconds =
if (configuredInterval > 0) {
configuredInterval.toLong()
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommandBase.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommandBase.kt
index 89b9acc..8cfedc7 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommandBase.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/core/LunaticCommandBase.kt
@@ -33,7 +33,7 @@ abstract class LunaticCommandBase(
protected fun fail(
key: String,
args: Map<String, String> = emptyMap(),
- ): CommandResult = CommandResult.Failure(MessageFormatter.formatError(languageManager.getMessage(key, args)))
+ ): CommandResult = CommandResult.Failure(languageManager.getMessage(key, args))
/**
* A successful result carrying the localized message at [key].
@@ -41,7 +41,7 @@ abstract class LunaticCommandBase(
protected fun ok(
key: String,
args: Map<String, String> = emptyMap(),
- ): CommandResult = CommandResult.SuccessWithMessage(MessageFormatter.format(languageManager.getMessage(key, args)))
+ ): CommandResult = CommandResult.SuccessWithMessage(languageManager.getMessage(key, args))
/**
* Helper method for checking player-only restriction.
@@ -70,8 +70,8 @@ abstract class LunaticCommandBase(
): Int {
when (result) {
is CommandResult.Success -> {}
- is CommandResult.SuccessWithMessage -> ctx.reply(result.message)
- is CommandResult.Failure -> ctx.reply(result.message)
+ is CommandResult.SuccessWithMessage -> ctx.reply(MessageFormatter.format(result.message))
+ is CommandResult.Failure -> ctx.reply(MessageFormatter.formatError(result.message))
is CommandResult.InvalidUsage ->
ctx.reply(
Component
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelCreateCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelCreateCommand.kt
index d1db42d..ac4c78a 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelCreateCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelCreateCommand.kt
@@ -13,7 +13,6 @@ import dev.m1sk9.lunaticChat.paper.command.annotation.PlayerOnly
import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
import dev.m1sk9.lunaticChat.paper.command.core.LunaticSubCommand
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
@@ -121,9 +120,7 @@ class ChannelCreateCommand(
successMessage
}
- CommandResult.SuccessWithMessage(
- MessageFormatter.format(message),
- )
+ CommandResult.SuccessWithMessage(message)
},
onFailure = { error ->
when (error) {
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 37764bf..2ce24b6 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
@@ -1,113 +1,104 @@
package dev.m1sk9.lunaticChat.paper.config
-import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig
-import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
-import dev.m1sk9.lunaticChat.paper.config.key.JapaneseConversionFeatureConfig
-import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
-import dev.m1sk9.lunaticChat.paper.config.key.QuickRepliesFeatureConfig
-import dev.m1sk9.lunaticChat.paper.config.key.VelocityIntegrationConfig
-import dev.m1sk9.lunaticChat.paper.i18n.Language
-import org.bukkit.configuration.file.FileConfiguration
+import com.charleskorn.kaml.EmptyYamlDocumentException
+import com.charleskorn.kaml.Yaml
+import com.charleskorn.kaml.YamlConfiguration
+import com.charleskorn.kaml.YamlException
+import com.charleskorn.kaml.YamlMap
+import com.charleskorn.kaml.YamlNode
+import com.charleskorn.kaml.YamlPath
+import com.charleskorn.kaml.YamlPathSegment
+import java.util.logging.Level
+import java.util.logging.Logger
/**
- * Manages loading and parsing of plugin configuration.
- * Converted from singleton to dependency injection pattern for better testability.
+ * Reads config.yml into [LunaticChatConfiguration].
+ *
+ * The file is deserialized directly rather than copied key by key, so a default lives only on the
+ * 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.
*/
-class ConfigManager {
- fun loadConfiguration(configFile: FileConfiguration): LunaticChatConfiguration {
- val loadedConfig =
- LunaticChatConfiguration(
- features =
- FeaturesConfig(
- quickReplies =
- QuickRepliesFeatureConfig(
- enabled =
- configFile.getBoolean("features.quickReplies.enabled", true),
- ),
- japaneseConversion =
- JapaneseConversionFeatureConfig(
- enabled = configFile.getBoolean("features.japaneseConversion.enabled", false),
- cacheMaxEntries = configFile.getInt("features.japaneseConversion.cache.maxEntries", 500),
- cacheSaveIntervalSeconds =
- configFile.getInt(
- "features.japaneseConversion.cache.saveIntervalSeconds",
- 300,
- ),
- cacheFilePath =
- configFile.getString(
- "features.japaneseConversion.cache.filePath",
- "conversion_cache.json",
- )!!,
- apiTimeout =
- configFile.getLong(
- "features.japaneseConversion.api.timeout",
- 3000,
- ),
- apiRetryAttempts = configFile.getInt("features.japaneseConversion.api.retryAttempts", 2),
- ),
- channelChat =
- ChannelChatFeatureConfig(
- enabled = configFile.getBoolean("features.channelChat.enabled", false),
- maxChannelsPerServer = configFile.getInt("features.channelChat.maxChannelsPerServer", 0),
- maxMembersPerChannel = configFile.getInt("features.channelChat.maxMembersPerChannel", 0),
- maxMembershipPerPlayer = configFile.getInt("features.channelChat.maxMembershipPerPlayer", 0),
- ),
- velocityIntegration =
- VelocityIntegrationConfig(
- enabled = configFile.getBoolean("features.velocityIntegration.enabled", false),
- crossServerGlobalChat =
- configFile.getBoolean(
- "features.velocityIntegration.crossServerGlobalChat",
- false,
- ),
- crossServerDirectMessage =
- configFile.getBoolean(
- "features.velocityIntegration.crossServerDirectMessage",
- false,
- ),
- serverName =
- configFile.getString(
- "features.velocityIntegration.serverName",
- "Unknown",
- ) ?: "Unknown",
- messageDeduplicationCacheSize =
- configFile.getInt(
- "features.velocityIntegration.messageDeduplicationCacheSize",
- 100,
- ),
- ),
- ),
- messageFormat =
- MessageFormatConfig(
- directMessageFormat =
- configFile.getString(
- "messageFormat.directMessageFormat",
- "§7[§e{sender} §7>> §e{recipient}§7] §f{message}",
- )!!,
- channelMessageFormat =
- configFile.getString(
- "messageFormat.channelMessageFormat",
- "§7[§b#{channel}§7] §e{sender}: §f{message}",
- )!!,
- crossServerGlobalChatFormat =
- configFile.getString(
- "messageFormat.crossServerGlobalChatFormat",
- "§7[§6{server}§7] §e{sender}: §f{message}",
- )!!,
- ),
- debug = configFile.getBoolean("debug", false),
- checkForUpdates = configFile.getBoolean("checkForUpdates", false),
- userSettingsFilePath =
- configFile.getString(
- "userSettingsFilePath",
- "player-settings.yaml",
- )!!,
- language =
- Language.fromCode(
- configFile.getString("language", "en")!!,
- ),
- )
+class ConfigManager(
+ private val logger: Logger,
+) {
+ private val yaml =
+ Yaml(
+ configuration =
+ YamlConfiguration(
+ // A config.yml from a newer build, or one carrying a key we have retired,
+ // should not stop the plugin from starting.
+ strictMode = false,
+ ),
+ )
- return loadedConfig
+ /**
+ * Parses [contents] as config.yml.
+ *
+ * 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 {
+ 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 (e: 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()
+ } 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)
+ }
+
+ // 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)
+ } 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.
+ val setting = e.path.settingKeys()
+ val remaining =
+ document.without(setting)
+ ?: return allDefaults("config.yml could not be read", e)
+ logger.warning("${setting.joinToString(".")} in config.yml fell back to its default: ${e.message}")
+ 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("config.yml could not be read", e)
+ }
+ }
+ }
+
+ private fun allDefaults(
+ what: String,
+ cause: Exception,
+ ): LunaticChatConfiguration {
+ logger.log(
+ Level.SEVERE,
+ "$what, so EVERY setting fell back to its default (fix the reported value and restart): ${cause.message}",
+ cause,
+ )
+ return LunaticChatConfiguration()
+ }
+
+ /** The config.yml keys leading to the node this path points at, outermost first. */
+ private fun YamlPath.settingKeys(): List<String> = segments.filterIsInstance<YamlPathSegment.MapElementKey>().map { it.key }
+
+ /** A copy of this document without [keys], or null when that entry is not there to remove. */
+ private fun YamlNode.without(keys: List<String>): YamlNode? {
+ if (this !is YamlMap || keys.isEmpty()) return null
+ val key = entries.keys.firstOrNull { it.content == keys.first() } ?: return null
+ if (keys.size == 1) return YamlMap(entries - key, path)
+ val remaining = entries.getValue(key).without(keys.drop(1)) ?: return null
+ return YamlMap(entries + (key to remaining), path)
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
new file mode 100644
index 0000000..3c93fb8
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LenientBoolean.kt
@@ -0,0 +1,53 @@
+package dev.m1sk9.lunaticChat.paper.config
+
+import com.charleskorn.kaml.YamlException
+import com.charleskorn.kaml.YamlInput
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+/**
+ * A Boolean that also accepts the spellings YAML 1.1 counted as boolean.
+ *
+ * Bukkit read config.yml as YAML 1.1, where `yes`, `no`, `on` and `off` are booleans; kaml reads
+ * YAML 1.2, where they are plain strings. Rejecting them would take a file that has worked for
+ * releases and quietly reset the setting - and for `checkForUpdates: no` the default is the
+ * opposite of what the file says, so the operator would get behaviour they had turned off.
+ */
+typealias LenientBoolean =
+ @Serializable(with = LenientBooleanSerializer::class)
+ Boolean
+
+object LenientBooleanSerializer : KSerializer<Boolean> {
+ private val trueWords = setOf("true", "yes", "on", "y")
+ private val falseWords = setOf("false", "no", "off", "n")
+
+ // STRING rather than BOOLEAN: the point is to read the scalar before YAML 1.2 decides it is not
+ // a boolean at all.
+ override val descriptor = PrimitiveSerialDescriptor("LenientBoolean", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): Boolean {
+ // Captured before decoding: ConfigManager prunes the offending setting by path, and only a
+ // YamlException carries one. A plain SerializationException would escape it and cost the
+ // operator the whole file.
+ val path = (decoder as? YamlInput)?.node?.path
+ val raw = decoder.decodeString()
+ return when (raw.lowercase()) {
+ in trueWords -> true
+ in falseWords -> false
+ else -> {
+ val reason = "expected true/false, yes/no or on/off but found '$raw'"
+ throw path?.let { YamlException(reason, it) } ?: SerializationException(reason)
+ }
+ }
+ }
+
+ override fun serialize(
+ encoder: Encoder,
+ value: Boolean,
+ ) = encoder.encodeString(value.toString())
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
index 92bc157..facd6ac 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/LunaticChatConfiguration.kt
@@ -3,12 +3,23 @@ package dev.m1sk9.lunaticChat.paper.config
import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
import dev.m1sk9.lunaticChat.paper.i18n.Language
+import dev.m1sk9.lunaticChat.paper.i18n.LanguageSerializer
+import kotlinx.serialization.Serializable
+/**
+ * The shape of config.yml.
+ *
+ * Every property carries the default that config.yml documents, and it is the only place that
+ * default is written: the file is deserialized straight into this tree, so a key the user has not
+ * set simply falls back here.
+ */
+@Serializable
data class LunaticChatConfiguration(
- val features: FeaturesConfig,
- val messageFormat: MessageFormatConfig,
- val debug: Boolean = false,
+ val features: FeaturesConfig = FeaturesConfig(),
+ val messageFormat: MessageFormatConfig = MessageFormatConfig(),
+ val debug: LenientBoolean = false,
val userSettingsFilePath: String = "player-settings.yaml",
- val checkForUpdates: Boolean = true,
+ val checkForUpdates: LenientBoolean = true,
+ @Serializable(with = LanguageSerializer::class)
val language: Language = Language.EN,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt
index 9a64428..b46147d 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelChatFeatureConfig.kt
@@ -1,10 +1,11 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
@Serializable
data class ChannelChatFeatureConfig(
- val enabled: Boolean,
+ val enabled: LenientBoolean = false,
val maxChannelsPerServer: Int = 0,
val maxMembersPerChannel: Int = 0,
val maxMembershipPerPlayer: Int = 0,
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt
index f036620..1a1d23b 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/ChannelMessageLoggingConfig.kt
@@ -1,5 +1,6 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
import kotlinx.serialization.Serializable
/**
@@ -14,7 +15,7 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class ChannelMessageLoggingConfig(
- val enabled: Boolean = true,
+ val enabled: LenientBoolean = true,
val retentionDays: Int = 30,
val maxFileSizeMB: Int = 100,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt
index 044838d..69e1e1b 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt
@@ -1,8 +1,11 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import kotlinx.serialization.Serializable
+
+@Serializable
data class FeaturesConfig(
- val quickReplies: QuickRepliesFeatureConfig,
- val japaneseConversion: JapaneseConversionFeatureConfig,
- val channelChat: ChannelChatFeatureConfig,
- val velocityIntegration: VelocityIntegrationConfig,
+ val quickReplies: QuickRepliesFeatureConfig = QuickRepliesFeatureConfig(),
+ val japaneseConversion: JapaneseConversionFeatureConfig = JapaneseConversionFeatureConfig(),
+ val channelChat: ChannelChatFeatureConfig = ChannelChatFeatureConfig(),
+ val velocityIntegration: VelocityIntegrationConfig = VelocityIntegrationConfig(),
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt
index c329085..845a294 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt
@@ -1,10 +1,36 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
+import kotlinx.serialization.Serializable
+
+/**
+ * @property enabled Whether romaji-to-Japanese conversion is offered to players
+ * @property cache Conversion cache tuning
+ * @property api Google IME request tuning
+ */
+@Serializable
data class JapaneseConversionFeatureConfig(
- val enabled: Boolean,
- val cacheMaxEntries: Int,
- val cacheSaveIntervalSeconds: Int,
- val cacheFilePath: String,
- val apiTimeout: Long,
- val apiRetryAttempts: Int,
+ val enabled: LenientBoolean = false,
+ val cache: ConversionCacheConfig = ConversionCacheConfig(),
+ val api: ConversionApiConfig = ConversionApiConfig(),
+)
+
+/**
+ * @property maxEntries Upper bound on cached word conversions
+ * @property saveIntervalSeconds How often the cache is flushed to disk
+ * @property filePath Cache file, relative to the plugin data folder
+ */
+@Serializable
+data class ConversionCacheConfig(
+ val maxEntries: Int = 500,
+ val saveIntervalSeconds: Int = 300,
+ val filePath: String = "conversion_cache.json",
+)
+
+/**
+ * @property timeout Per-request timeout in milliseconds
+ */
+@Serializable
+data class ConversionApiConfig(
+ val timeout: Long = 3000,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt
index d31ec4e..481f259 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt
@@ -1,5 +1,8 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import kotlinx.serialization.Serializable
+
+@Serializable
data class MessageFormatConfig(
val directMessageFormat: String = "§7[§e{sender} §7>> §e{recipient}§7] §f{message}",
val channelMessageFormat: String = "§7[§b#{channel}§7] §e{sender}: §f{message}",
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt
index cf0c00d..524cc57 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt
@@ -1,5 +1,9 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
+import kotlinx.serialization.Serializable
+
+@Serializable
data class QuickRepliesFeatureConfig(
- val enabled: Boolean,
+ val enabled: LenientBoolean = true,
)
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt
index 2b2babb..927b7a0 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt
@@ -1,9 +1,13 @@
package dev.m1sk9.lunaticChat.paper.config.key
+import dev.m1sk9.lunaticChat.paper.config.LenientBoolean
+import kotlinx.serialization.Serializable
+
+@Serializable
data class VelocityIntegrationConfig(
- val enabled: Boolean = false,
- val crossServerGlobalChat: Boolean = false,
- val crossServerDirectMessage: Boolean = false,
+ val enabled: LenientBoolean = false,
+ val crossServerGlobalChat: LenientBoolean = false,
+ val crossServerDirectMessage: LenientBoolean = false,
val serverName: String = "Unknown",
val messageDeduplicationCacheSize: Int = 100,
)
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/CacheData.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt
index 1f335b4..06bf3f0 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/CacheData.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt
@@ -1,4 +1,4 @@
-package dev.m1sk9.lunaticChat.engine.converter
+package dev.m1sk9.lunaticChat.paper.converter
import kotlinx.serialization.Serializable
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
index c550f09..cba62af 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
@@ -1,6 +1,5 @@
package dev.m1sk9.lunaticChat.paper.converter
-import dev.m1sk9.lunaticChat.engine.converter.CacheData
import dev.m1sk9.lunaticChat.paper.writeTextAtomically
import kotlinx.serialization.json.Json
import java.nio.file.Path
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/GoogleIMEClient.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt
index 4a23bc7..0689ad4 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/GoogleIMEClient.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt
@@ -1,4 +1,4 @@
-package dev.m1sk9.lunaticChat.engine.converter
+package dev.m1sk9.lunaticChat.paper.converter
import io.ktor.client.HttpClient
import io.ktor.client.call.body
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/KanaConverter.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt
index bdbdf69..544eea7 100644
--- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/converter/KanaConverter.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt
@@ -1,4 +1,4 @@
-package dev.m1sk9.lunaticChat.engine.converter
+package dev.m1sk9.lunaticChat.paper.converter
/**
* Converts romanji text to hiragana using Trie data structure.
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
index 530d3dc..100aeb3 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
@@ -1,7 +1,5 @@
package dev.m1sk9.lunaticChat.paper.converter
-import dev.m1sk9.lunaticChat.engine.converter.GoogleIMEClient
-import dev.m1sk9.lunaticChat.engine.converter.KanaConverter
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt
new file mode 100644
index 0000000..080f0d5
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageSerializer.kt
@@ -0,0 +1,28 @@
+package dev.m1sk9.lunaticChat.paper.i18n
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+/**
+ * Reads a [Language] from its code, as written in config.yml.
+ *
+ * Goes through [Language.fromCode] rather than the enum name so the lookup stays
+ * case-insensitive and an unrecognised code falls back to English, instead of failing the whole
+ * configuration over one typo.
+ */
+object LanguageSerializer : KSerializer<Language> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Language", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): Language = Language.fromCode(decoder.decodeString())
+
+ override fun serialize(
+ encoder: Encoder,
+ value: Language,
+ ) {
+ encoder.encodeString(value.code)
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
index ccd8d40..8328ad8 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
@@ -132,7 +132,7 @@ class CrossServerDirectMessageManager(
val messageKey =
when (error.reason) {
PluginMessage.DirectMessageError.Reason.SERVER_NOT_FOUND -> "directMessage.remoteServerNotFound"
- else -> "directMessage.remoteTargetOffline"
+ PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE -> "directMessage.remoteTargetOffline"
}
val text =
languageManager.getMessage(
diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml
index 111d90b..37bfdc6 100644
--- a/platform-paper/src/main/resources/config.yml
+++ b/platform-paper/src/main/resources/config.yml
@@ -46,8 +46,6 @@ features:
api:
# Specify the timeout duration (in milliseconds) for API requests to the Romanization conversion service.
timeout: 3000
- # Specify the number of retry attempts for failed API requests to the Romanization conversion service.
- retryAttempts: 2
channelChat:
# If enabled, channel-based chat functionality will be activated.
enabled: false
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 2d2a7c7..4bca21f 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
@@ -6,6 +6,7 @@ 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.key.ChannelChatFeatureConfig
+import dev.m1sk9.lunaticChat.paper.config.key.ConversionCacheConfig
import dev.m1sk9.lunaticChat.paper.config.key.FeaturesConfig
import dev.m1sk9.lunaticChat.paper.config.key.JapaneseConversionFeatureConfig
import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
@@ -86,11 +87,7 @@ object TestUtils {
japaneseConversion =
JapaneseConversionFeatureConfig(
enabled = japaneseConversionEnabled,
- cacheMaxEntries = 500,
- cacheSaveIntervalSeconds = 300,
- cacheFilePath = "test-conversion-cache.json",
- apiTimeout = 3000,
- apiRetryAttempts = 2,
+ cache = ConversionCacheConfig(filePath = "test-conversion-cache.json"),
),
channelChat =
ChannelChatFeatureConfig(
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 3f936f6..d415e97 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
@@ -1,288 +1,292 @@
package dev.m1sk9.lunaticChat.paper.config
+import dev.m1sk9.lunaticChat.paper.TestUtils
import dev.m1sk9.lunaticChat.paper.i18n.Language
-import io.mockk.every
-import io.mockk.mockk
-import org.bukkit.configuration.file.FileConfiguration
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
-import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
- * Tests for ConfigManager to ensure proper configuration loading and parsing.
+ * Tests for ConfigManager.
+ *
+ * These parse real YAML rather than a mocked Bukkit FileConfiguration, so they exercise the same
+ * path the plugin uses at startup - including what happens to a file that is incomplete, stale or
+ * malformed.
*/
class ConfigManagerTest {
- private fun createMockConfig(values: Map<String, Any> = emptyMap()): FileConfiguration {
- val config = mockk<FileConfiguration>(relaxed = true)
-
- // Set up default values
- every { config.getBoolean(any(), any()) } answers {
- val key = firstArg<String>()
- val default = secondArg<Boolean>()
- (values[key] as? Boolean) ?: default
- }
-
- every { config.getInt(any(), any()) } answers {
- val key = firstArg<String>()
- val default = secondArg<Int>()
- (values[key] as? Int) ?: default
- }
-
- every { config.getLong(any(), any()) } answers {
- val key = firstArg<String>()
- val default = secondArg<Long>()
- (values[key] as? Long) ?: default
- }
-
- every { config.getString(any(), any()) } answers {
- val key = firstArg<String>()
- val default = secondArg<String>()
- (values[key] as? String) ?: default
- }
-
- every { config.getString(any()) } answers {
- val key = firstArg<String>()
- values[key] as? String
- }
-
- return config
+ private fun load(yaml: String) = ConfigManager(TestUtils.TestLogger()).loadConfiguration(yaml)
+
+ /** The file shipped in resources, which is what a fresh install actually reads. */
+ private val bundledConfig: String =
+ checkNotNull(javaClass.classLoader.getResourceAsStream("config.yml")) {
+ "config.yml missing from resources"
+ }.bufferedReader().use { it.readText() }
+
+ @Test
+ fun `the bundled config parses`() {
+ val config = load(bundledConfig)
+
+ assertFalse(config.debug)
+ assertEquals("player-settings.yaml", config.userSettingsFilePath)
+ assertEquals(Language.EN, config.language)
}
@Test
- fun `loadConfiguration should load all default values`() {
- val configManager = ConfigManager()
- val mockConfig = createMockConfig()
-
- val configuration = configManager.loadConfiguration(mockConfig)
-
- assertNotNull(configuration)
- assertTrue(configuration.features.quickReplies.enabled)
- assertFalse(configuration.features.japaneseConversion.enabled)
- assertFalse(configuration.features.channelChat.enabled)
- assertFalse(configuration.debug)
- assertFalse(configuration.checkForUpdates)
- assertEquals(Language.EN, configuration.language)
+ fun `the bundled config agrees with the declared defaults`() {
+ // The data class is meant to be the single source of every default. If config.yml ships a
+ // different value for a key, one of the two is lying to the operator.
+ assertEquals(LunaticChatConfiguration(), load(bundledConfig))
}
@Test
- fun `loadConfiguration should load quick replies configuration`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "features.quickReplies.enabled" to false,
- ),
- )
+ fun `an empty document falls back to defaults`() {
+ assertEquals(LunaticChatConfiguration(), load("{}"))
+ }
- val configuration = configManager.loadConfiguration(mockConfig)
+ @Test
+ fun `a config missing a section keeps that section's defaults`() {
+ val config = load("debug: true")
- assertFalse(configuration.features.quickReplies.enabled)
+ assertTrue(config.debug)
+ assertEquals(LunaticChatConfiguration().features, config.features)
+ assertEquals(LunaticChatConfiguration().messageFormat, config.messageFormat)
}
@Test
- fun `loadConfiguration should load Japanese conversion configuration`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "features.japaneseConversion.enabled" to true,
- "features.japaneseConversion.cache.maxEntries" to 1000,
- "features.japaneseConversion.cache.saveIntervalSeconds" to 600,
- "features.japaneseConversion.cache.filePath" to "custom_cache.json",
- "features.japaneseConversion.api.timeout" to 5000L,
- "features.japaneseConversion.api.retryAttempts" to 3,
- ),
+ fun `values present in the file win over the defaults`() {
+ val config =
+ load(
+ """
+ debug: true
+ checkForUpdates: false
+ userSettingsFilePath: "custom.yaml"
+ language: "ja"
+ """.trimIndent(),
)
- val configuration = configManager.loadConfiguration(mockConfig)
+ assertTrue(config.debug)
+ assertFalse(config.checkForUpdates)
+ assertEquals("custom.yaml", config.userSettingsFilePath)
+ assertEquals(Language.JA, config.language)
+ }
- assertTrue(configuration.features.japaneseConversion.enabled)
- assertEquals(1000, configuration.features.japaneseConversion.cacheMaxEntries)
- assertEquals(600, configuration.features.japaneseConversion.cacheSaveIntervalSeconds)
- assertEquals("custom_cache.json", configuration.features.japaneseConversion.cacheFilePath)
- assertEquals(5000L, configuration.features.japaneseConversion.apiTimeout)
- assertEquals(3, configuration.features.japaneseConversion.apiRetryAttempts)
+ @Test
+ fun `an unknown language code falls back to English`() {
+ assertEquals(Language.EN, load("""language: "kl"""").language)
}
@Test
- fun `loadConfiguration should load channel chat configuration`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "features.channelChat.enabled" to true,
- "features.channelChat.maxChannelsPerServer" to 20,
- "features.channelChat.maxMembersPerChannel" to 100,
- "features.channelChat.maxMembershipPerPlayer" to 10,
- ),
- )
+ fun `a language code is matched regardless of case`() {
+ assertEquals(Language.JA, load("""language: "JA"""").language)
+ }
- val configuration = configManager.loadConfiguration(mockConfig)
+ @Test
+ fun `nested japanese conversion settings are read`() {
+ val config =
+ load(
+ """
+ features:
+ japaneseConversion:
+ enabled: true
+ cache:
+ maxEntries: 1000
+ saveIntervalSeconds: 600
+ filePath: "custom_cache.json"
+ api:
+ timeout: 5000
+ """.trimIndent(),
+ )
- assertTrue(configuration.features.channelChat.enabled)
- assertEquals(20, configuration.features.channelChat.maxChannelsPerServer)
- assertEquals(100, configuration.features.channelChat.maxMembersPerChannel)
- assertEquals(10, configuration.features.channelChat.maxMembershipPerPlayer)
+ val japanese = config.features.japaneseConversion
+ assertTrue(japanese.enabled)
+ assertEquals(1000, japanese.cache.maxEntries)
+ assertEquals(600, japanese.cache.saveIntervalSeconds)
+ assertEquals("custom_cache.json", japanese.cache.filePath)
+ assertEquals(5000L, japanese.api.timeout)
}
@Test
- fun `loadConfiguration should load message format configuration`() {
- val configManager = ConfigManager()
- val customDMFormat = "DM: {sender} -> {recipient}: {message}"
- val customChannelFormat = "[{channel}] {sender}: {message}"
- val mockConfig =
- createMockConfig(
- mapOf(
- "messageFormat.directMessageFormat" to customDMFormat,
- "messageFormat.channelMessageFormat" to customChannelFormat,
- ),
+ fun `channel message logging is read from the file`() {
+ // This block was documented in config.yml but never parsed, so editing it did nothing.
+ val config =
+ load(
+ """
+ features:
+ channelChat:
+ enabled: true
+ messageLogging:
+ enabled: false
+ retentionDays: 7
+ maxFileSizeMB: 20
+ """.trimIndent(),
)
- val configuration = configManager.loadConfiguration(mockConfig)
-
- assertEquals(customDMFormat, configuration.messageFormat.directMessageFormat)
- assertEquals(customChannelFormat, configuration.messageFormat.channelMessageFormat)
+ val logging = config.features.channelChat.messageLogging
+ assertFalse(logging.enabled)
+ assertEquals(7, logging.retentionDays)
+ assertEquals(20, logging.maxFileSizeMB)
}
@Test
- fun `loadConfiguration should load debug and update check settings`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "debug" to true,
- "checkForUpdates" to true,
- ),
+ fun `velocity integration settings are read from the file`() {
+ val config =
+ load(
+ """
+ features:
+ velocityIntegration:
+ enabled: true
+ crossServerGlobalChat: true
+ crossServerDirectMessage: true
+ serverName: "survival"
+ messageDeduplicationCacheSize: 250
+ """.trimIndent(),
)
- val configuration = configManager.loadConfiguration(mockConfig)
-
- assertTrue(configuration.debug)
- assertTrue(configuration.checkForUpdates)
+ val velocity = config.features.velocityIntegration
+ assertTrue(velocity.enabled)
+ assertTrue(velocity.crossServerGlobalChat)
+ assertTrue(velocity.crossServerDirectMessage)
+ assertEquals("survival", velocity.serverName)
+ assertEquals(250, velocity.messageDeduplicationCacheSize)
}
@Test
- fun `loadConfiguration should load custom settings file path`() {
- val configManager = ConfigManager()
- val customPath = "custom-player-settings.yaml"
- val mockConfig =
- createMockConfig(
- mapOf(
- "userSettingsFilePath" to customPath,
- ),
+ fun `message formats are read from the file`() {
+ val config =
+ load(
+ """
+ messageFormat:
+ directMessageFormat: "DM {sender} {message}"
+ channelMessageFormat: "CH {channel} {message}"
+ crossServerGlobalChatFormat: "GL {server} {message}"
+ """.trimIndent(),
)
- val configuration = configManager.loadConfiguration(mockConfig)
+ assertEquals("DM {sender} {message}", config.messageFormat.directMessageFormat)
+ assertEquals("CH {channel} {message}", config.messageFormat.channelMessageFormat)
+ assertEquals("GL {server} {message}", config.messageFormat.crossServerGlobalChatFormat)
+ }
+
+ @Test
+ fun `a key this build no longer knows is ignored`() {
+ // An operator upgrading from a build that had extra keys must still be able to start.
+ val config = load("debug: true\nsomeRetiredOption: 42")
- assertEquals(customPath, configuration.userSettingsFilePath)
+ assertTrue(config.debug)
}
@Test
- fun `loadConfiguration should load Japanese language`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "language" to "ja",
- ),
- )
+ fun `a document that is not YAML at all says that every setting was reset`() {
+ val logger = TestUtils.TestLogger()
- val configuration = configManager.loadConfiguration(mockConfig)
+ val config = ConfigManager(logger).loadConfiguration("features: [this is not a map")
- assertEquals(Language.JA, configuration.language)
+ assertEquals(LunaticChatConfiguration(), config)
+ assertTrue(logger.severeMessages.any { it.contains("EVERY setting") })
}
@Test
- fun `loadConfiguration should handle unknown language code`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "language" to "fr", // French not supported
- ),
- )
+ fun `a file the YAML reader rejects before parsing still leaves the plugin running`() {
+ val logger = TestUtils.TestLogger()
+ // What a config.yml saved as UTF-16 looks like once it is read back as UTF-8: the scanner
+ // refuses it over the NUL bytes, before anything is YAML.
+ val notUtf8 = String("debug: true".toByteArray(Charsets.UTF_16), Charsets.UTF_8)
- val configuration = configManager.loadConfiguration(mockConfig)
+ val config = ConfigManager(logger).loadConfiguration(notUtf8)
- // Should fall back to English
- assertEquals(Language.EN, configuration.language)
+ assertEquals(LunaticChatConfiguration(), config)
+ assertTrue(logger.severeMessages.any { it.contains("EVERY setting") })
}
@Test
- fun `loadConfiguration should use default values when keys are missing`() {
- val configManager = ConfigManager()
- val mockConfig = createMockConfig(emptyMap())
-
- val configuration = configManager.loadConfiguration(mockConfig)
-
- // All features should have their defaults
- assertTrue(configuration.features.quickReplies.enabled)
- assertEquals(500, configuration.features.japaneseConversion.cacheMaxEntries)
- assertEquals(300, configuration.features.japaneseConversion.cacheSaveIntervalSeconds)
- assertEquals("conversion_cache.json", configuration.features.japaneseConversion.cacheFilePath)
- assertEquals(3000L, configuration.features.japaneseConversion.apiTimeout)
- assertEquals(2, configuration.features.japaneseConversion.apiRetryAttempts)
- assertEquals(0, configuration.features.channelChat.maxChannelsPerServer)
- assertEquals(0, configuration.features.channelChat.maxMembersPerChannel)
- assertEquals(0, configuration.features.channelChat.maxMembershipPerPlayer)
+ fun `a leading byte order mark does not cost the first setting`() {
+ assertTrue(load("\uFEFFdebug: true").debug)
}
@Test
- fun `loadConfiguration can be called multiple times`() {
- val configManager = ConfigManager()
- val mockConfig1 =
- createMockConfig(
- mapOf("debug" to true),
- )
- val mockConfig2 =
- createMockConfig(
- mapOf("debug" to false),
- )
+ fun `a file holding only comments is not reported as a failure`() {
+ val logger = TestUtils.TestLogger()
- val config1 = configManager.loadConfiguration(mockConfig1)
- val config2 = configManager.loadConfiguration(mockConfig2)
+ val config = ConfigManager(logger).loadConfiguration("# everything left at its default\n")
- assertTrue(config1.debug)
- assertFalse(config2.debug)
+ assertEquals(LunaticChatConfiguration(), config)
+ assertTrue(logger.severeMessages.isEmpty())
}
@Test
- fun `loadConfiguration should handle all features enabled`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "features.quickReplies.enabled" to true,
- "features.japaneseConversion.enabled" to true,
- "features.channelChat.enabled" to true,
- ),
- )
+ fun `the boolean spellings Bukkit accepted are still booleans`() {
+ // Bukkit read config.yml as YAML 1.1, where these are booleans. A file written against that
+ // must keep meaning what it says.
+ val config = load("debug: yes\ncheckForUpdates: off")
- val configuration = configManager.loadConfiguration(mockConfig)
+ assertTrue(config.debug)
+ assertFalse(config.checkForUpdates)
+ }
- assertTrue(configuration.features.quickReplies.enabled)
- assertTrue(configuration.features.japaneseConversion.enabled)
- assertTrue(configuration.features.channelChat.enabled)
+ @Test
+ fun `boolean spellings are matched regardless of case`() {
+ assertTrue(load("debug: YES").debug)
}
@Test
- fun `loadConfiguration should handle all features disabled`() {
- val configManager = ConfigManager()
- val mockConfig =
- createMockConfig(
- mapOf(
- "features.quickReplies.enabled" to false,
- "features.japaneseConversion.enabled" to false,
- "features.channelChat.enabled" to false,
- ),
+ fun `an unreadable setting falls back alone and leaves the rest of the file standing`() {
+ val logger = TestUtils.TestLogger()
+
+ val config =
+ ConfigManager(logger).loadConfiguration(
+ """
+ debug: perhaps
+ userSettingsFilePath: "custom.yaml"
+ language: "ja"
+ """.trimIndent(),
)
- val configuration = configManager.loadConfiguration(mockConfig)
+ assertFalse(config.debug)
+ assertEquals("custom.yaml", config.userSettingsFilePath)
+ assertEquals(Language.JA, config.language)
+ assertTrue(logger.warningMessages.any { it.contains("debug") })
+ assertTrue(logger.severeMessages.isEmpty())
+ }
+
+ @Test
+ fun `an unreadable nested setting leaves its siblings standing`() {
+ val logger = TestUtils.TestLogger()
+
+ val config =
+ ConfigManager(logger).loadConfiguration(
+ """
+ features:
+ velocityIntegration:
+ enabled: true
+ serverName: "survival"
+ messageDeduplicationCacheSize: "not a number"
+ """.trimIndent(),
+ )
+
+ val velocity = config.features.velocityIntegration
+ assertTrue(velocity.enabled)
+ assertEquals("survival", velocity.serverName)
+ assertEquals(
+ LunaticChatConfiguration().features.velocityIntegration.messageDeduplicationCacheSize,
+ velocity.messageDeduplicationCacheSize,
+ )
+ assertTrue(logger.warningMessages.any { it.contains("features.velocityIntegration.messageDeduplicationCacheSize") })
+ }
+
+ @Test
+ fun `several unreadable settings each fall back without taking the others`() {
+ val config =
+ load(
+ """
+ debug: perhaps
+ checkForUpdates: sometimes
+ userSettingsFilePath: "custom.yaml"
+ """.trimIndent(),
+ )
- assertFalse(configuration.features.quickReplies.enabled)
- assertFalse(configuration.features.japaneseConversion.enabled)
- assertFalse(configuration.features.channelChat.enabled)
+ assertFalse(config.debug)
+ assertTrue(config.checkForUpdates)
+ assertEquals("custom.yaml", config.userSettingsFilePath)
}
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheDataTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheDataTest.kt
new file mode 100644
index 0000000..f44dd10
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheDataTest.kt
@@ -0,0 +1,31 @@
+package dev.m1sk9.lunaticChat.paper.converter
+
+import kotlinx.serialization.json.Json
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class CacheDataTest {
+ private val json = Json
+
+ @Test
+ fun `CacheData should store version and entries`() {
+ val data = CacheData(version = "1.0", entries = mapOf("hello" to "こんにちは"))
+ assertEquals("1.0", data.version)
+ assertEquals("こんにちは", data.entries["hello"])
+ }
+
+ @Test
+ fun `CacheData serialization round-trip should preserve all fields`() {
+ val original =
+ CacheData(
+ version = "2.0",
+ entries = mapOf("hello" to "こんにちは", "world" to "世界"),
+ )
+
+ val serialized = json.encodeToString(CacheData.serializer(), original)
+ val deserialized = json.decodeFromString(CacheData.serializer(), serialized)
+
+ assertEquals(original.version, deserialized.version)
+ assertEquals(original.entries, deserialized.entries)
+ }
+}
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/converter/KanaConverterTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverterTest.kt
index 5ba7f0e..798772d 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/converter/KanaConverterTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverterTest.kt
@@ -1,4 +1,4 @@
-package dev.m1sk9.lunaticChat.engine.converter
+package dev.m1sk9.lunaticChat.paper.converter
import kotlin.test.Test
import kotlin.test.assertEquals
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
index eba2c54..c8fe680 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
@@ -1,7 +1,7 @@
package dev.m1sk9.lunaticChat.paper.converter
-import dev.m1sk9.lunaticChat.engine.converter.GoogleIMEClient
import dev.m1sk9.lunaticChat.paper.TestUtils
+import dev.m1sk9.lunaticChat.paper.converter.GoogleIMEClient
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
diff --git a/platform-velocity/build.gradle.kts b/platform-velocity/build.gradle.kts
index 65bd27f..9f35fae 100644
--- a/platform-velocity/build.gradle.kts
+++ b/platform-velocity/build.gradle.kts
@@ -13,7 +13,7 @@ repositories {
}
dependencies {
- // Engine module (provides serialization, coroutines, ktor)
+ // Engine module (provides serialization)
api(project(":engine"))
// Velocity-specific dependencies
diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt
index f9cc52d..70ca525 100644
--- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt
+++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt
@@ -78,7 +78,7 @@ class CrossServerDirectMessageRelay(
private fun sendError(
sourceServer: RegisteredServer,
message: PluginMessage.DirectMessageRelay,
- reason: String,
+ reason: PluginMessage.DirectMessageError.Reason,
) {
val error =
PluginMessage.DirectMessageError(
diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md
index 859b617..78430f3 100644
--- a/website/src/docs/configuration.md
+++ b/website/src/docs/configuration.md
@@ -32,7 +32,6 @@ LunaticChat's configuration is managed in `plugins/LunaticChat/config.yml`. A de
| `cache.saveIntervalSeconds` | Int | `300` | Interval (in seconds) for saving cache to disk |
| `cache.filePath` | String | `"conversion_cache.json"` | Path to the cache file |
| `api.timeout` | Long | `3000` | API request timeout (in milliseconds) |
-| `api.retryAttempts` | Int | `2` | Number of retries on API request failure |
### Channel Chat (`features.channelChat`)
diff --git a/website/src/docs/features/japanese-conversion.md b/website/src/docs/features/japanese-conversion.md
index 3bcd52b..ec1df1b 100644
--- a/website/src/docs/features/japanese-conversion.md
+++ b/website/src/docs/features/japanese-conversion.md
@@ -57,6 +57,5 @@ Settings related to the connection to the Google IME API.
| Setting Key | Default | Description |
|-------------|---------|-------------|
| `api.timeout` | `3000` | Request timeout (milliseconds) |
-| `api.retryAttempts` | `2` | Number of retry attempts on failure |
If the API times out or fails, the message is sent in hiragana as-is.
diff --git a/website/src/ja/docs/configuration.md b/website/src/ja/docs/configuration.md
index 60a79c7..c2c8c12 100644
--- a/website/src/ja/docs/configuration.md
+++ b/website/src/ja/docs/configuration.md
@@ -32,7 +32,6 @@ LunaticChat の設定は `plugins/LunaticChat/config.yml` で管理されます
| `cache.saveIntervalSeconds` | Int | `300` | キャッシュのディスク保存間隔(秒) |
| `cache.filePath` | String | `"conversion_cache.json"` | キャッシュファイルのパス |
| `api.timeout` | Long | `3000` | API リクエストのタイムアウト(ミリ秒) |
-| `api.retryAttempts` | Int | `2` | API リクエスト失敗時のリトライ回数 |
### チャンネルチャット (`features.channelChat`)
diff --git a/website/src/ja/docs/features/japanese-conversion.md b/website/src/ja/docs/features/japanese-conversion.md
index e5e751b..b94542e 100644
--- a/website/src/ja/docs/features/japanese-conversion.md
+++ b/website/src/ja/docs/features/japanese-conversion.md
@@ -57,6 +57,5 @@ Google IME API への接続に関する設定です.
| 設定キー | デフォルト | 説明 |
|----------|-----------|------|
| `api.timeout` | `3000` | リクエストタイムアウト (ミリ秒) |
-| `api.retryAttempts` | `2` | 失敗時のリトライ回数 |
API がタイムアウトまたは失敗した場合,ひらがなのまま送信されます.