diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-05 02:47:26 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-05 02:47:26 +0900 |
| commit | 7c544224291e8ed7e23dcf97ea3cc6f42588c338 (patch) | |
| tree | d1c9212600bc111031ac8a8a16b8f2fac0e08d6d | |
| parent | f4bff879ac2f4323011d41e4f7ee85a9695515a5 (diff) | |
| parent | c29d1621b26f9c1dfbf0a26f4433996060babf10 (diff) | |
| download | LunaticChat-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
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 がタイムアウトまたは失敗した場合,ひらがなのまま送信されます. |
