diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-01-10 13:21:32 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-10 13:21:32 +0900 |
| commit | 7f8cc94aaa28340f084f1b700fd07295757df76a (patch) | |
| tree | 8b4e956cfb2eebfa4286e79de6dcfe6978d1bf88 | |
| parent | b1c44dc64111710d4197d34e17e912e4f291602e (diff) | |
| parent | 1aec4ff5aad3a1b25bf7cf904c2eb44cc173ae98 (diff) | |
| download | LunaticChat-7f8cc94aaa28340f084f1b700fd07295757df76a.tar.gz LunaticChat-7f8cc94aaa28340f084f1b700fd07295757df76a.tar.bz2 LunaticChat-7f8cc94aaa28340f084f1b700fd07295757df76a.zip | |
Merge pull request #14 from m1sk9/feat/convert-japanese-feature
feat: Add Convert Japanese feature
26 files changed, 1230 insertions, 58 deletions
@@ -16,7 +16,7 @@ LunaticChat is a Minecraft chat plugin providing 1on1 messaging, quick reply fun 1. **Always support the latest version** while maintaining backward compatibility (e.g., 1.21.x) 2. **Maintainability**: Design for extensibility and easy maintenance -3. **Use Paper's LifecycleEventManager** for command registration (not RuneCore's implementation) +3. **Use Paper's LifecycleEventManager** for command registration 4. **Chat logs must be compatible** with CoreProtect and similar logging plugins ## Project Structure @@ -61,24 +61,99 @@ LunaticChat/ **Conversion Timing**: When player sends message (`AsyncChatEvent` fires) -**Implementation Strategy**: -1. **Phase 1**: Implement with Kotlin sealed classes - - Define romaji mappings as sealed class hierarchy - - If this becomes unmanageable, proceed to Phase 2 -2. **Phase 2**: Use Map-based approach -3. **Phase 3**: External API with request limiting and caching +**Architecture**: Simple cache + Google IME API approach -**Example sealed class structure**: +``` +┌─────────────────────────────────────────┐ +│ Player Input (Romanji) │ +└──────────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Check Memory Cache │ +├─────────────────────────────────────────┤ +│ Hit: Return cached result (< 1ms) │ +│ Miss: Call Google IME API │ +│ → Save to cache │ +│ → Queue async disk save │ +└──────────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ Converted Text (Japanese) │ +└─────────────────────────────────────────┘ +``` + +**Key Components**: + +1. **RomanjiConverter** - Main conversion coordinator +2. **ConversionCache** - Two-tier caching (memory + disk) + - Memory: ConcurrentHashMap for instant access + - Disk: JSON file loaded on startup, saved periodically +3. **GoogleIMEClient** - HTTP client for Google Transliterate API + +**Cache Strategy**: +- Load cache from disk on plugin enable (once) +- All conversions check memory cache first +- Cache misses trigger API call and store result +- Periodic async saves (every 5 minutes) + final save on disable +- LRU eviction when max entries (500) exceeded + +**Performance**: +- Cached conversions: < 1ms +- API calls: < 3000ms (first time only per phrase) +- Disk I/O: Async, no gameplay impact +- Memory footprint: ~25KB for 500 entries +- Startup load time: < 10ms + +**Example Implementation**: +```kotlin +class RomanjiConverter( + private val cache: ConversionCache, + private val apiClient: GoogleIMEClient +) { + suspend fun convert(input: String): String { + // Check cache first + cache.get(input)?.let { return it } + + // Call Google IME API + val result = apiClient.convert(input) + + // Store in cache + cache.put(input, result) + + return result + } +} +``` + +**Cache Implementation**: ```kotlin -sealed class RomajiMapping { - abstract val romaji: String - abstract val hiragana: String +class ConversionCache( + private val cacheFile: Path, + private val maxEntries: Int = 500 +) { + private val memoryCache = ConcurrentHashMap<String, String>() + private val saveQueue = AtomicBoolean(false) - data object A : RomajiMapping() { - override val romaji = "a" - override val hiragana = "あ" + fun loadFromDisk() { + if (!cacheFile.exists()) return + val data = Json.decodeFromString<CacheData>(cacheFile.readText()) + memoryCache.putAll(data.entries) + } + + fun get(key: String): String? = memoryCache[key] + + fun put(key: String, value: String) { + if (memoryCache.size >= maxEntries) evictOldest() + memoryCache[key] = value + queueDiskSave() + } + + fun saveToDisk() { + val data = CacheData(version = "1.0", entries = memoryCache.toMap()) + cacheFile.writeText(Json.encodeToString(data)) } - // ... more mappings } ``` @@ -100,6 +175,30 @@ data class PlayerChatSettings( ) ``` +**Cache Data Model**: +```kotlin +@Serializable +data class CacheData( + val version: String, + val entries: Map<String, String> +) +``` + +## Configuration + +```yaml +features: + japaneseConversion: + enabled: true + cache: + maxEntries: 500 + saveIntervalSeconds: 300 # 5 minutes + cacheFile: "conversion-cache.json" + api: + timeout: 3000 # milliseconds + retryCount: 2 +``` + ## Future Features (Post v0.1.0) ### Cross-Server Chat (Velocity) @@ -129,8 +228,49 @@ fun onChat(event: AsyncChatEvent) { val settings = settingsManager.get(player.uniqueId) if (settings.japaneseConversionEnabled) { - val converted = romajiConverter.convert(/* message */) - // Modify message (don't cancel event for CoreProtect compatibility) + val plainText = (event.message() as? TextComponent)?.content() ?: return + val converted = runBlocking { romajiConverter.convert(plainText) } + event.message(Component.text(converted)) + } +} +``` + +## Plugin Lifecycle + +```kotlin +class LunaticChat : JavaPlugin() { + private lateinit var romanjiConverter: RomanjiConverter + + override fun onEnable() { + // Load cache on startup + val cache = ConversionCache( + cacheFile = dataFolder.resolve("conversion-cache.json").toPath(), + maxEntries = config.getInt("features.japaneseConversion.cache.maxEntries", 500) + ) + cache.loadFromDisk() + + // Initialize converter + val apiClient = GoogleIMEClient( + timeout = config.getInt("features.japaneseConversion.api.timeout", 3000).milliseconds + ) + romanjiConverter = RomanjiConverter(cache, apiClient) + + // Periodic save task + val saveInterval = config.getLong( + "features.japaneseConversion.cache.saveIntervalSeconds", 300 + ) * 20L // Convert seconds to ticks + + server.scheduler.runTaskTimerAsynchronously(this, { + cache.saveToDisk() + }, saveInterval, saveInterval) + + logger.info("Japanese conversion system initialized") + } + + override fun onDisable() { + // Final save on shutdown + cache.saveToDisk() + logger.info("Cache saved on shutdown") } } ``` @@ -141,8 +281,28 @@ fun onChat(event: AsyncChatEvent) { - Command aliases must be properly registered - Settings file location: `plugins/LunaticChat/settings/` - Cache settings in memory to avoid frequent file I/O +- Cache file location: `plugins/LunaticChat/conversion-cache.json` +- All disk I/O is async to prevent blocking game thread + +## Performance Considerations + +### Memory Usage +- 500 entries × ~50 bytes average = ~25KB +- Parse-time memory consumption: < 100KB +- Negligible impact on Minecraft server + +### Disk I/O +- **Startup**: Once (< 10ms for 500 entries) +- **Runtime**: Periodic saves every 5 minutes (async) +- **Shutdown**: Once (final save) + +### Network +- No network calls after cache hit +- Each unique phrase calls Google API only once ## Development Environment - Shell: Fish -- Always use Japanese punctuation (,.) in responses. +- Java: 21+ +- Gradle: 9+ +- Kotlin: 2.3.0+ diff --git a/engine/build.gradle.kts b/engine/build.gradle.kts index 7afc78f..60c5c7b 100644 --- a/engine/build.gradle.kts +++ b/engine/build.gradle.kts @@ -3,6 +3,4 @@ plugins { kotlin("plugin.serialization") } -dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") -} +dependencies {} diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/ConversionCacheFileNotFoundException.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/ConversionCacheFileNotFoundException.kt new file mode 100644 index 0000000..49dd5c1 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/ConversionCacheFileNotFoundException.kt @@ -0,0 +1,7 @@ +package dev.m1sk9.lunaticChat.engine.exception + +import java.nio.file.Path + +class ConversionCacheFileNotFoundException( + cacheFilePath: Path, +) : Exception("Conversion cache file not found at path: $cacheFilePath") diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt index 304d531..5dfe8bf 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt @@ -7,5 +7,7 @@ sealed class LunaticChatPermissionNode( object Reply : LunaticChatPermissionNode("lunaticchat.command.reply") + object JapaneseToggle : LunaticChatPermissionNode("lunaticchat.command.jp") + object Spy : LunaticChatPermissionNode("lunaticchat.spy") } diff --git a/platform-paper/build.gradle.kts b/platform-paper/build.gradle.kts index 4624a4b..8b45d6a 100644 --- a/platform-paper/build.gradle.kts +++ b/platform-paper/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") + kotlin("plugin.serialization") id("com.gradleup.shadow") id("xyz.jpenilla.run-paper") id("org.jetbrains.dokka") @@ -14,7 +15,11 @@ repositories { dependencies { api(project(":engine")) compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") - implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlin:kotlin-reflect:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("io.ktor:ktor-client-core:3.3.3") + implementation("io.ktor:ktor-client-cio:3.3.3") } tasks { 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 00f2eb9..f40ea80 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 @@ -3,12 +3,21 @@ package dev.m1sk9.lunaticChat.paper import dev.m1sk9.lunaticChat.paper.command.core.CommandRegistry import dev.m1sk9.lunaticChat.paper.command.handler.DirectMessageHandler import dev.m1sk9.lunaticChat.paper.command.impl.ReplyCommand +import dev.m1sk9.lunaticChat.paper.command.impl.RomajiConvertToggleCommand import dev.m1sk9.lunaticChat.paper.command.impl.TellCommand import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager import dev.m1sk9.lunaticChat.paper.config.ConfigManager +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.listener.PlayerChatListener import dev.m1sk9.lunaticChat.paper.listener.PlayerPresenceListener +import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO import org.bukkit.event.Listener import org.bukkit.plugin.java.JavaPlugin +import kotlin.time.Duration.Companion.milliseconds class LunaticChat : JavaPlugin(), @@ -16,6 +25,8 @@ class LunaticChat : lateinit var directMessageHandler: DirectMessageHandler private lateinit var commandRegistry: CommandRegistry + private var romajiConverter: RomanjiConverter? = null + private var playerSettingsManager: PlayerSettingsManager? = null override fun onEnable() { saveDefaultConfig() @@ -26,18 +37,72 @@ class LunaticChat : logger.info("Debug: $lunaticChatConfiguration") } - directMessageHandler = DirectMessageHandler() + if (lunaticChatConfiguration.features.japaneseConversion.enabled) { + val settingsDir = dataFolder.resolve(lunaticChatConfiguration.features.japaneseConversion.settingsDirectory).toPath() + playerSettingsManager = + PlayerSettingsManager( + settingsDirectory = settingsDir, + plugin = this, + logger = logger, + ) + playerSettingsManager!!.initializeDirectory() + + val cache = + ConversionCache( + cacheFile = dataFolder.resolve(lunaticChatConfiguration.features.japaneseConversion.cacheFilePath).toPath(), + maxEntries = lunaticChatConfiguration.features.japaneseConversion.cacheMaxEntries, + plugin = this, + logger = logger, + ) + cache.loadFromDisk() + + val httpClient = HttpClient(CIO) + val apiClient = + GoogleIMEClient( + timeout = lunaticChatConfiguration.features.japaneseConversion.apiTimeout.milliseconds, + httpClient = httpClient, + ) + romajiConverter = RomanjiConverter(cache, apiClient, logger) + + val saveInterval = lunaticChatConfiguration.features.japaneseConversion.cacheSaveIntervalSeconds * 20L + server.scheduler.runTaskTimerAsynchronously( + this, + Runnable { + cache.saveToDisk() + }, + saveInterval, + saveInterval, + ) + + server.pluginManager.registerEvents(PlayerChatListener(romajiConverter!!, playerSettingsManager!!), this) + logger.info("Japanese conversion feature enabled.") + } + + directMessageHandler = + DirectMessageHandler( + settingsManager = playerSettingsManager, + romanjiConverter = romajiConverter, + ) commandRegistry = CommandRegistry(this) .registerAll( TellCommand(this, directMessageHandler), - ReplyCommand(this, directMessageHandler), ) + if (lunaticChatConfiguration.features.quickRepliesEnabled.enabled) { + commandRegistry.registerAll( + ReplyCommand(this, directMessageHandler), + ) + } + if (lunaticChatConfiguration.features.japaneseConversion.enabled) { + commandRegistry.registerAll( + RomajiConvertToggleCommand(this, playerSettingsManager!!), + ) + } commandRegistry.initialize() server.pluginManager.registerEvents(SpyPermissionManager, this) - server.pluginManager.registerEvents(PlayerPresenceListener(), this) + server.pluginManager.registerEvents(PlayerPresenceListener(this, playerSettingsManager), this) logger.info("LunaticChat enabled.") } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt index 0a28a2d..853dbb5 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt @@ -2,6 +2,8 @@ package dev.m1sk9.lunaticChat.paper.command.handler import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager import dev.m1sk9.lunaticChat.paper.config.ConfigManager +import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter +import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager import net.kyori.adventure.text.Component import net.kyori.adventure.text.event.ClickEvent import org.bukkit.Bukkit @@ -13,7 +15,10 @@ import java.util.concurrent.ConcurrentHashMap * Manages direct message state including reply targets. * Tracks the last player who messaged each player for /reply functionality. */ -class DirectMessageHandler { +class DirectMessageHandler( + private val settingsManager: PlayerSettingsManager?, + private val romanjiConverter: RomanjiConverter?, +) { private var lunaticChatConfiguration = ConfigManager.getConfiguration() private val lastMessager: ConcurrentHashMap<UUID, UUID> = ConcurrentHashMap() @@ -61,26 +66,40 @@ class DirectMessageHandler { /** * Sends a direct message from one player to another. * Handles formatting and recording the conversation. + * Applies romaji-to-Japanese conversion if sender has it enabled. * * @return true if message was sent successfully */ - fun sendDirectMessage( + suspend fun sendDirectMessage( sender: Player, recipient: Player, message: String, ): Boolean { recordMessage(sender, recipient) + + val senderSettings = settingsManager?.getSettings(sender.uniqueId) + val displayMessage = + senderSettings + ?.takeIf { it.japaneseConversionEnabled } + ?.let { + romanjiConverter + ?.runCatching { + "$message §e(${convert(message)})" + }?.getOrNull() + } ?: message + val format = lunaticChatConfiguration.messageFormat.directMessageFormat - val formattedMessage = formatMessage(format, sender.name, recipient.name, message) - SpyPermissionManager.getDirectMessageSpyPlayers().values.forEach { - if (it.isOnline && it.uniqueId != sender.uniqueId && it.uniqueId != recipient.uniqueId) { - it.sendMessage(formattedMessage) - } - } + val spyMessage = formatMessage(format, sender.name, recipient.name, message) + SpyPermissionManager + .getDirectMessageSpyPlayers() + .values + .filter { it.isOnline && it.uniqueId !in setOf(sender.uniqueId, recipient.uniqueId) } + .forEach { it.sendMessage(spyMessage) } - sender.sendMessage(formattedMessage) - recipient.sendMessage(formattedMessage) + val userMessage = formatMessage(format, sender.name, recipient.name, displayMessage) + sender.sendMessage(userMessage) + recipient.sendMessage(userMessage) return true } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt index 37e0a82..1b8407d 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt @@ -13,13 +13,10 @@ import dev.m1sk9.lunaticChat.paper.command.core.LunaticCommand import dev.m1sk9.lunaticChat.paper.command.handler.DirectMessageHandler import io.papermc.paper.command.brigadier.CommandSourceStack import io.papermc.paper.command.brigadier.Commands +import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor -/** - * Quick reply command for responding to the last person who messaged you. - * Usage: /reply <message> - */ @Command( name = "reply", aliases = ["r"], @@ -61,7 +58,9 @@ class ReplyCommand( .color(NamedTextColor.RED), ) - dmHandler.sendDirectMessage(sender, target, message) + runBlocking { + dmHandler.sendDirectMessage(sender, target, message) + } return CommandResult.Success } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/RomajiConvertToggleCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/RomajiConvertToggleCommand.kt new file mode 100644 index 0000000..b23c957 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/RomajiConvertToggleCommand.kt @@ -0,0 +1,89 @@ +package dev.m1sk9.lunaticChat.paper.command.impl + +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode +import dev.m1sk9.lunaticChat.paper.LunaticChat +import dev.m1sk9.lunaticChat.paper.command.annotation.Command +import dev.m1sk9.lunaticChat.paper.command.annotation.Permission +import dev.m1sk9.lunaticChat.paper.command.annotation.PlayerOnly +import dev.m1sk9.lunaticChat.paper.command.core.CommandContext +import dev.m1sk9.lunaticChat.paper.command.core.CommandResult +import dev.m1sk9.lunaticChat.paper.command.core.LunaticCommand +import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager +import io.papermc.paper.command.brigadier.CommandSourceStack +import io.papermc.paper.command.brigadier.Commands +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor + +@Command( + name = "jp", + aliases = [], + description = "Toggle romaji to Japanese conversion for your messages", +) +@Permission(LunaticChatPermissionNode.JapaneseToggle::class) +@PlayerOnly +class RomajiConvertToggleCommand( + plugin: LunaticChat, + private val settingsManager: PlayerSettingsManager, +) : LunaticCommand(plugin) { + override fun buildCommand(): LiteralArgumentBuilder<CommandSourceStack> = + Commands + .literal(name) + .then( + Commands + .literal("on") + .executes { ctx -> + val context = wrapContext(ctx) + checkPlayerOnly(context)?.let { return@executes handleResult(context, it) } + val result = execute(context, true) + handleResult(context, result) + }, + ).then( + Commands + .literal("off") + .executes { ctx -> + val context = wrapContext(ctx) + checkPlayerOnly(context)?.let { return@executes handleResult(context, it) } + val result = execute(context, false) + handleResult(context, result) + }, + ).executes { ctx -> + val context = wrapContext(ctx) + checkPlayerOnly(context)?.let { return@executes handleResult(context, it) } + val result = showStatus(context) + handleResult(context, result) + } + + private fun execute( + ctx: CommandContext, + enable: Boolean, + ): CommandResult { + val player = ctx.requirePlayer() + val currentSettings = settingsManager.getSettings(player.uniqueId) + val updatedSettings = currentSettings.copy(japaneseConversionEnabled = enable) + settingsManager.updateSettings(updatedSettings) + + val statusText = if (enable) "enabled" else "disabled" + val message = + Component + .text("Japanese conversion has been $statusText.") + .color(NamedTextColor.GREEN) + + player.sendMessage(message) + return CommandResult.Success + } + + private fun showStatus(ctx: CommandContext): CommandResult { + val player = ctx.requirePlayer() + val settings = settingsManager.getSettings(player.uniqueId) + + val statusText = if (settings.japaneseConversionEnabled) "enabled" else "disabled" + val message = + Component + .text("Japanese conversion is currently $statusText.") + .color(NamedTextColor.YELLOW) + + player.sendMessage(message) + return CommandResult.Success + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt index 54ab036..80773e1 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt @@ -15,15 +15,12 @@ import dev.m1sk9.lunaticChat.paper.command.core.LunaticCommand import dev.m1sk9.lunaticChat.paper.command.handler.DirectMessageHandler import io.papermc.paper.command.brigadier.CommandSourceStack import io.papermc.paper.command.brigadier.Commands +import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component import net.kyori.adventure.text.format.NamedTextColor import org.bukkit.Bukkit import java.util.concurrent.CompletableFuture -/** - * Direct messaging command. - * Usage: /tell <player> <message> - */ @Command( name = "tell", aliases = ["t", "msg", "m", "w", "whisper"], @@ -79,7 +76,10 @@ class TellCommand( .color(NamedTextColor.RED), ) } - directMessageHandler.sendDirectMessage(sender, recipient, message) + + runBlocking { + directMessageHandler.sendDirectMessage(sender, recipient, message) + } return CommandResult.Success } 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 d10a4e6..64e35b2 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,7 +1,9 @@ package dev.m1sk9.lunaticChat.paper.config 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 org.bukkit.configuration.file.FileConfiguration object ConfigManager { @@ -15,8 +17,37 @@ object ConfigManager { LunaticChatConfiguration( features = FeaturesConfig( - japaneseConversionEnabled = configFile.getBoolean("features.japaneseConversionEnabled", false), - quickRepliesEnabled = configFile.getBoolean("features.quickRepliesEnabled", true), + quickRepliesEnabled = + 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), + settingsDirectory = + configFile.getString( + "features.japaneseConversion.settings.directory", + "settings", + )!!, + ), ), messageFormat = MessageFormatConfig( 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 56ee7ec..703ddcc 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,6 +1,6 @@ package dev.m1sk9.lunaticChat.paper.config.key data class FeaturesConfig( - val japaneseConversionEnabled: Boolean, - val quickRepliesEnabled: Boolean, + val quickRepliesEnabled: QuickRepliesFeatureConfig, + val japaneseConversion: JapaneseConversionFeatureConfig, ) 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 new file mode 100644 index 0000000..df72f1d --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt @@ -0,0 +1,11 @@ +package dev.m1sk9.lunaticChat.paper.config.key + +data class JapaneseConversionFeatureConfig( + val enabled: Boolean, + val cacheMaxEntries: Int, + val cacheSaveIntervalSeconds: Int, + val cacheFilePath: String, + val apiTimeout: Long, + val apiRetryAttempts: Int, + val settingsDirectory: String = "settings", +) 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 new file mode 100644 index 0000000..cf0c00d --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt @@ -0,0 +1,5 @@ +package dev.m1sk9.lunaticChat.paper.config.key + +data class QuickRepliesFeatureConfig( + val enabled: Boolean, +) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt new file mode 100644 index 0000000..06bf3f0 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt @@ -0,0 +1,9 @@ +package dev.m1sk9.lunaticChat.paper.converter + +import kotlinx.serialization.Serializable + +@Serializable +data class CacheData( + val version: String, + val entries: Map<String, String>, +) 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 new file mode 100644 index 0000000..b9b378f --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt @@ -0,0 +1,129 @@ +package dev.m1sk9.lunaticChat.paper.converter + +import kotlinx.serialization.json.Json +import org.bukkit.Bukkit +import org.bukkit.plugin.java.JavaPlugin +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger +import kotlin.io.path.bufferedReader +import kotlin.io.path.exists +import kotlin.io.path.writeText + +class ConversionCache( + private val cacheFile: Path, + private val maxEntries: Int = 500, + private val plugin: JavaPlugin, + private val logger: Logger, +) { + private val conversionMemoryCache = ConcurrentHashMap<String, String>() + private val conversionSaveQueue = AtomicBoolean(false) + + companion object { + private const val CACHE_VERSION = "1" + } + + /** + * Loads the conversion cache from disk into memory. + * If the cache file does not exist or version is incompatible, initializes it with an empty cache. + */ + fun loadFromDisk() { + if (!cacheFile.exists()) { + logger.info("Cache file not found, initializing new cache file at: $cacheFile") + initializeEmptyCache() + return + } + + try { + val jsonBuffer = cacheFile.bufferedReader().use { it.readText() } + val cacheData = Json.decodeFromString<CacheData>(jsonBuffer) + + if (cacheData.version != CACHE_VERSION) { + logger.warning("Cache version mismatch (expected: $CACHE_VERSION, found: ${cacheData.version}). Reinitializing cache.") + initializeEmptyCache() + return + } + + conversionMemoryCache.putAll(cacheData.entries) + logger.info("Loaded ${conversionMemoryCache.size} cache entries from disk.") + } catch (e: Exception) { + logger.severe("Failed to load conversion cache from disk: ${e.message}") + logger.info("Reinitializing cache due to error.") + initializeEmptyCache() + } + } + + private fun initializeEmptyCache() { + val emptyData = CacheData(version = CACHE_VERSION, entries = emptyMap()) + val jsonBuffer = Json.encodeToString(CacheData.serializer(), emptyData) + cacheFile.writeText(jsonBuffer) + } + + /** + * Retrieves a cached conversion result by key. + * + * @param key The key for the cached conversion. + * @return The cached conversion result, or null if not found. + */ + fun get(key: String): String? = conversionMemoryCache[key] + + /** + * Stores a conversion result in the cache. + * + * @param key The key for the conversion. + * @param value The conversion result to cache. + */ + fun put( + key: String, + value: String, + ) { + if (conversionMemoryCache.size >= maxEntries) { + evictOldestEntry() + } + + conversionMemoryCache[key] = value + queueSaveToDisk() + } + + /** + * Saves the conversion cache from memory to disk. + * This operation is performed asynchronously. + * + * @throws Exception if an error occurs during the save operation. + */ + fun saveToDisk() { + try { + val data = + CacheData( + version = CACHE_VERSION, + entries = conversionMemoryCache.toMap(), + ) + val jsonBuffer = Json.encodeToString(CacheData.serializer(), data) + cacheFile.writeText(jsonBuffer) + logger.info("Saved ${conversionMemoryCache.size} cache entries to disk.") + } catch (e: Exception) { + logger.severe("Failed to save conversion cache to disk: ${e.message}") + } + } + + private fun queueSaveToDisk() { + if (conversionSaveQueue.compareAndSet(false, true)) { + Bukkit.getScheduler().runTaskAsynchronously( + plugin, + Runnable { + Thread.sleep(5000) // 5 seconds delay to batch multiple save requests + conversionSaveQueue.set(true) + saveToDisk() + }, + ) + } + } + + private fun evictOldestEntry() { + val toRemove = conversionMemoryCache.size / 10 + conversionMemoryCache.keys.take(toRemove).forEach { + conversionMemoryCache.remove(it) + } + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt new file mode 100644 index 0000000..3d8b00a --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt @@ -0,0 +1,56 @@ +package dev.m1sk9.lunaticChat.paper.converter + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class GoogleIMEClient( + private val timeout: Duration = 3.seconds, + private val httpClient: HttpClient, +) { + suspend fun convert(input: String): String = + withContext(Dispatchers.IO) { + withTimeout(timeout) { + val res = + httpClient.get("https://www.google.com/transliterate") { + url { + parameters.append("langpair", "ja-Hira|ja") + parameters.append("text", input) + } + } + + if (res.status != io.ktor.http.HttpStatusCode.OK) { + throw Exception("Google IME API returned status code: ${res.status.value}") + } + + val jsonData = res.body<String>() + parseResponse(jsonData) + } + } + + private fun parseResponse(data: String): String { + val parsed = Json.parseToJsonElement(data) + val resultArray = parsed.jsonArray + + // Response format: [["input", ["candidate1", "candidate2", ...]], ...] + if (resultArray.isNotEmpty()) { + val firstResult = resultArray[0].jsonArray + if (firstResult.size >= 2) { + val candidates = firstResult[1].jsonArray + if (candidates.isNotEmpty()) { + return candidates[0].jsonPrimitive.content + } + } + } + + throw IllegalStateException("No conversion result found in the response.") + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt new file mode 100644 index 0000000..3868ae6 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt @@ -0,0 +1,297 @@ +package dev.m1sk9.lunaticChat.paper.converter + +/** + * Converts romanji text to hiragana using Trie data structure. + */ +object KanaConverter { + sealed class TrieNode { + data class Leaf( + val value: String, + ) : TrieNode() + + data class Branch( + val children: Map<Char, TrieNode>, + val value: String? = null, + ) : TrieNode() + } + + private val romanjiTrie: TrieNode = buildTrie() + + private fun buildTrie(): TrieNode { + val mappings = + listOf( + // 3文字変換 + "kya" to "きゃ", + "kyi" to "きぃ", + "kyu" to "きゅ", + "kye" to "きぇ", + "kyo" to "きょ", + "gya" to "ぎゃ", + "gyi" to "ぎぃ", + "gyu" to "ぎゅ", + "gye" to "ぎぇ", + "gyo" to "ぎょ", + "sha" to "しゃ", + "shi" to "し", + "shu" to "しゅ", + "she" to "しぇ", + "sho" to "しょ", + "sya" to "しゃ", + "syi" to "しぃ", + "syu" to "しゅ", + "sye" to "しぇ", + "syo" to "しょ", + "zya" to "じゃ", + "zyi" to "じぃ", + "zyu" to "じゅ", + "zye" to "じぇ", + "zyo" to "じょ", + "jya" to "じゃ", + "jyi" to "じぃ", + "jyu" to "じゅ", + "jye" to "じぇ", + "jyo" to "じょ", + "cha" to "ちゃ", + "chi" to "ち", + "chu" to "ちゅ", + "che" to "ちぇ", + "cho" to "ちょ", + "tya" to "ちゃ", + "tyi" to "ちぃ", + "tyu" to "ちゅ", + "tye" to "ちぇ", + "tyo" to "ちょ", + "dya" to "ぢゃ", + "dyi" to "ぢぃ", + "dyu" to "ぢゅ", + "dye" to "ぢぇ", + "dyo" to "ぢょ", + "nya" to "にゃ", + "nyi" to "にぃ", + "nyu" to "にゅ", + "nye" to "にぇ", + "nyo" to "にょ", + "hya" to "ひゃ", + "hyi" to "ひぃ", + "hyu" to "ひゅ", + "hye" to "ひぇ", + "hyo" to "ひょ", + "bya" to "びゃ", + "byi" to "びぃ", + "byu" to "びゅ", + "bye" to "びぇ", + "byo" to "びょ", + "pya" to "ぴゃ", + "pyi" to "ぴぃ", + "pyu" to "ぴゅ", + "pye" to "ぴぇ", + "pyo" to "ぴょ", + "mya" to "みゃ", + "myi" to "みぃ", + "myu" to "みゅ", + "mye" to "みぇ", + "myo" to "みょ", + "rya" to "りゃ", + "ryi" to "りぃ", + "ryu" to "りゅ", + "rye" to "りぇ", + "ryo" to "りょ", + "tsu" to "つ", + "thi" to "てぃ", + "dhi" to "でぃ", + "dhu" to "でゅ", + "wha" to "うぁ", + "whi" to "うぃ", + "whe" to "うぇ", + "who" to "うぉ", + // 2文字変換 + "ka" to "か", + "ki" to "き", + "ku" to "く", + "ke" to "け", + "ko" to "こ", + "ga" to "が", + "gi" to "ぎ", + "gu" to "ぐ", + "ge" to "げ", + "go" to "ご", + "sa" to "さ", + "si" to "し", + "su" to "す", + "se" to "せ", + "so" to "そ", + "za" to "ざ", + "zi" to "じ", + "zu" to "ず", + "ze" to "ぜ", + "zo" to "ぞ", + "ja" to "じゃ", + "ji" to "じ", + "ju" to "じゅ", + "je" to "じぇ", + "jo" to "じょ", + "ta" to "た", + "ti" to "ち", + "tu" to "つ", + "te" to "て", + "to" to "と", + "da" to "だ", + "di" to "ぢ", + "du" to "づ", + "de" to "で", + "do" to "ど", + "na" to "な", + "ni" to "に", + "nu" to "ぬ", + "ne" to "ね", + "no" to "の", + "ha" to "は", + "hi" to "ひ", + "hu" to "ふ", + "he" to "へ", + "ho" to "ほ", + "fu" to "ふ", + "ba" to "ば", + "bi" to "び", + "bu" to "ぶ", + "be" to "べ", + "bo" to "ぼ", + "pa" to "ぱ", + "pi" to "ぴ", + "pu" to "ぷ", + "pe" to "ぺ", + "po" to "ぽ", + "ma" to "ま", + "mi" to "み", + "mu" to "む", + "me" to "め", + "mo" to "も", + "ya" to "や", + "yi" to "い", + "yu" to "ゆ", + "ye" to "いぇ", + "yo" to "よ", + "ra" to "ら", + "ri" to "り", + "ru" to "る", + "re" to "れ", + "ro" to "ろ", + "wa" to "わ", + "wi" to "ゐ", + "wu" to "う", + "we" to "ゑ", + "wo" to "を", + "la" to "ら", + "li" to "り", + "lu" to "る", + "le" to "れ", + "lo" to "ろ", + "nn" to "ん", + // 1文字変換 + "a" to "あ", + "i" to "い", + "u" to "う", + "e" to "え", + "o" to "お", + "n" to "ん", + ) + + return insertAll(TrieNode.Branch(emptyMap()), mappings) + } + + private fun insertAll( + root: TrieNode, + mappings: List<Pair<String, String>>, + ): TrieNode { + var current = root + for ((key, value) in mappings) { + current = insert(current, key, value) + } + return current + } + + private fun insert( + node: TrieNode, + key: String, + value: String, + ): TrieNode { + if (key.isEmpty()) { + return when (node) { + is TrieNode.Branch -> TrieNode.Branch(node.children, value) + is TrieNode.Leaf -> TrieNode.Leaf(value) + } + } + + return when (node) { + is TrieNode.Branch -> { + val char = key[0] + val child = node.children[char] ?: TrieNode.Branch(emptyMap()) + val newChild = insert(child, key.substring(1), value) + TrieNode.Branch(node.children + (char to newChild), node.value) + } + is TrieNode.Leaf -> node + } + } + + /** + * Converts romanji text to hiragana. + * + * @param input The romanji text to convert + * @return The converted hiragana text + */ + fun toHiragana(input: String): String { + val result = StringBuilder() + var i = 0 + val lowerInput = input.lowercase() + + while (i < lowerInput.length) { + if (i + 1 < lowerInput.length) { + val current = lowerInput[i] + val next = lowerInput[i + 1] + if (current == next && current in "bcdfghjklmpqrstvwxyz") { + result.append('っ') + i++ + continue + } + } + + var node: TrieNode = romanjiTrie + var lastMatch: Pair<String, Int>? = null + var j = i + + while (j < lowerInput.length) { + node = + when (node) { + is TrieNode.Branch -> { + if (node.value != null) { + lastMatch = node.value to (j - i) + } + + node.children[lowerInput[j]] ?: break + } + is TrieNode.Leaf -> { + lastMatch = node.value to (j - i) + break + } + } + j++ + } + + if (node is TrieNode.Leaf) { + lastMatch = node.value to (j - i) + } else if (node is TrieNode.Branch && node.value != null) { + lastMatch = node.value to (j - i) + } + + if (lastMatch != null) { + result.append(lastMatch.first) + i += lastMatch.second + } else { + result.append(lowerInput[i]) + i++ + } + } + + return result.toString() + } +} 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 new file mode 100644 index 0000000..b280bd5 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt @@ -0,0 +1,42 @@ +package dev.m1sk9.lunaticChat.paper.converter + +import java.util.logging.Logger + +class RomanjiConverter( + private val cache: ConversionCache, + private val apiClient: GoogleIMEClient, + private val logger: Logger, +) { + /** + * Converts the given romaji input to Japanese using the API client. + * Utilizes a cache to store and retrieve previous conversion results. + * + * Step 1: Romanji -> Hiragana (using KanaConverter) + * Step 2: Hiragana -> Kanji/Kana (using Google IME API) + * + * @param input The romaji string to convert. + * @return The converted Japanese string, or the original input if conversion fails. + * @throws Exception if the conversion process encounters an error. + */ + suspend fun convert(input: String): String { + cache.get(input)?.let { + return it + } + + // Step 1: Romanji -> Hiragana + val hiragana = KanaConverter.toHiragana(input) + logger.info("Romanji -> Hiragana: $input -> $hiragana") + + // Step 2: Hiragana -> Kanji/Kana + val result = + try { + apiClient.convert(hiragana) + } catch (e: Exception) { + logger.warning("Failed to convert $hiragana: ${e.message}") + return hiragana // Return hiragana if API fails + } + + cache.put(input, result) + return result + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt new file mode 100644 index 0000000..eb1908d --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt @@ -0,0 +1,35 @@ +package dev.m1sk9.lunaticChat.paper.listener + +import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter +import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager +import io.papermc.paper.event.player.AsyncChatEvent +import kotlinx.coroutines.runBlocking +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener + +class PlayerChatListener( + private val converter: RomanjiConverter, + private val settingsManager: PlayerSettingsManager, +) : Listener { + private val plainTextSerializer = PlainTextComponentSerializer.plainText() + + @EventHandler(ignoreCancelled = true) + fun onChat(event: AsyncChatEvent) { + val player = event.player + val settings = settingsManager.getSettings(player.uniqueId) + if (!settings.japaneseConversionEnabled)return + + val message = event.message() + val originalMessage = plainTextSerializer.serialize(message) + + // AsyncChatEvent is already running on an async thread, so runBlocking is safe + val result = + runBlocking { + converter.convert(originalMessage) + } + + event.message(Component.text("$originalMessage §e($result)")) + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt index ad53929..579f50a 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt @@ -1,16 +1,24 @@ package dev.m1sk9.lunaticChat.paper.listener import dev.m1sk9.lunaticChat.paper.LunaticChat +import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager import org.bukkit.event.EventHandler import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent import org.bukkit.event.player.PlayerQuitEvent -class PlayerPresenceListener : Listener { +class PlayerPresenceListener( + private val lunaticChat: LunaticChat, + private val settingsManager: PlayerSettingsManager?, +) : Listener { @EventHandler(ignoreCancelled = true) - fun onQuit( - lunaticChat: LunaticChat, - event: PlayerQuitEvent, - ) { + fun onJoin(event: PlayerJoinEvent) { + settingsManager?.loadPlayerSettings(event.player.uniqueId) + } + + @EventHandler(ignoreCancelled = true) + fun onQuit(event: PlayerQuitEvent) { lunaticChat.directMessageHandler.clearPlayer(event.player) + settingsManager?.unloadPlayerSettings(event.player.uniqueId) } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerChatSettings.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerChatSettings.kt new file mode 100644 index 0000000..e84ae18 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerChatSettings.kt @@ -0,0 +1,17 @@ +package dev.m1sk9.lunaticChat.paper.settings + +import kotlinx.serialization.Serializable +import java.util.UUID + +/** + * Data model for per-player chat settings. + * + * @property uuid The unique identifier of the player + * @property japaneseConversionEnabled Whether romaji-to-Japanese conversion is enabled for this player + */ +@Serializable +data class PlayerChatSettings( + @Serializable(with = UUIDSerializer::class) + val uuid: UUID, + val japaneseConversionEnabled: Boolean = false, +) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt new file mode 100644 index 0000000..634036f --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt @@ -0,0 +1,148 @@ +package dev.m1sk9.lunaticChat.paper.settings + +import kotlinx.serialization.json.Json +import org.bukkit.Bukkit +import org.bukkit.plugin.java.JavaPlugin +import java.nio.file.Path +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Logger +import kotlin.io.path.bufferedReader +import kotlin.io.path.createDirectories +import kotlin.io.path.exists +import kotlin.io.path.writeText + +/** + * Manages per-player chat settings persistence. + * Provides in-memory cache with async disk I/O for player settings. + * + * @property settingsDirectory The directory where player settings files are stored + * @property plugin The plugin instance for scheduling async tasks + * @property logger The logger for logging operations + */ +class PlayerSettingsManager( + private val settingsDirectory: Path, + private val plugin: JavaPlugin, + private val logger: Logger, +) { + private val settingsCache = ConcurrentHashMap<UUID, PlayerChatSettings>() + private val saveQueue = ConcurrentHashMap<UUID, AtomicBoolean>() + + /** + * Initializes the settings directory if it doesn't exist. + */ + fun initializeDirectory() { + if (!settingsDirectory.exists()) { + settingsDirectory.createDirectories() + logger.info("Created player settings directory at: $settingsDirectory") + } + } + + /** + * Loads player settings from disk into memory. + * If the settings file doesn't exist, creates default settings. + * + * @param uuid The UUID of the player + * @return The loaded or newly created player settings + */ + fun loadPlayerSettings(uuid: UUID): PlayerChatSettings { + val settingsFile = settingsDirectory.resolve("$uuid.json") + + if (!settingsFile.exists()) { + logger.fine("Settings file not found for player $uuid, creating default settings.") + val defaultSettings = PlayerChatSettings(uuid = uuid, japaneseConversionEnabled = false) + settingsCache[uuid] = defaultSettings + queueSaveToDisk(uuid) + return defaultSettings + } + + try { + val jsonBuffer = settingsFile.bufferedReader().use { it.readText() } + val settings = Json.decodeFromString<PlayerChatSettings>(jsonBuffer) + settingsCache[uuid] = settings + logger.fine("Loaded settings for player $uuid from disk.") + return settings + } catch (e: Exception) { + logger.warning("Failed to load settings for player $uuid: ${e.message}. Using default settings.") + val defaultSettings = PlayerChatSettings(uuid = uuid, japaneseConversionEnabled = false) + settingsCache[uuid] = defaultSettings + return defaultSettings + } + } + + /** + * Retrieves settings from cache. + * If settings are not cached, creates and caches default settings. + * + * @param uuid The UUID of the player + * @return The player's settings + */ + fun getSettings(uuid: UUID): PlayerChatSettings = + settingsCache.getOrPut(uuid) { + PlayerChatSettings(uuid = uuid, japaneseConversionEnabled = false) + } + + /** + * Updates player settings in cache and queues async save to disk. + * + * @param settings The updated settings to save + */ + fun updateSettings(settings: PlayerChatSettings) { + settingsCache[settings.uuid] = settings + queueSaveToDisk(settings.uuid) + logger.fine("Updated settings for player ${settings.uuid}") + } + + /** + * Removes player settings from cache. + * Called when a player quits. Settings remain persisted on disk. + * + * @param uuid The UUID of the player + */ + fun unloadPlayerSettings(uuid: UUID) { + settingsCache.remove(uuid) + saveQueue.remove(uuid) + logger.fine("Unloaded settings for player $uuid from cache.") + } + + /** + * Saves player settings from memory to disk immediately. + * This operation is synchronous and should only be called from async context. + * + * @param uuid The UUID of the player + */ + private fun savePlayerSettings(uuid: UUID) { + val settings = settingsCache[uuid] ?: return + + try { + val settingsFile = settingsDirectory.resolve("$uuid.json") + val jsonBuffer = Json.encodeToString(PlayerChatSettings.serializer(), settings) + settingsFile.writeText(jsonBuffer) + logger.fine("Saved settings for player $uuid to disk.") + } catch (e: Exception) { + logger.severe("Failed to save settings for player $uuid: ${e.message}") + } + } + + /** + * Queues an async save operation for a player's settings. + * Multiple save requests within 5 seconds are batched into a single save operation. + * + * @param uuid The UUID of the player + */ + private fun queueSaveToDisk(uuid: UUID) { + val queueFlag = saveQueue.getOrPut(uuid) { AtomicBoolean(false) } + + if (queueFlag.compareAndSet(false, true)) { + Bukkit.getScheduler().runTaskAsynchronously( + plugin, + Runnable { + Thread.sleep(5000) // 5 seconds delay to batch multiple save requests + queueFlag.set(false) + savePlayerSettings(uuid) + }, + ) + } + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/UUIDSerializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/UUIDSerializer.kt new file mode 100644 index 0000000..5e5233f --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/UUIDSerializer.kt @@ -0,0 +1,27 @@ +package dev.m1sk9.lunaticChat.paper.settings + +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 +import java.util.UUID + +/** + * Custom serializer for UUID with kotlinx.serialization. + * kotlinx.serialization doesn't support UUID by default, so we need a custom serializer. + */ +object UUIDSerializer : KSerializer<UUID> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString()) + + override fun serialize( + encoder: Encoder, + value: UUID, + ) { + encoder.encodeString(value.toString()) + } +} diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml index dd5db15..68a4ad7 100644 --- a/platform-paper/src/main/resources/config.yml +++ b/platform-paper/src/main/resources/config.yml @@ -20,10 +20,21 @@ debug: false # ---------------------------------------------- features: - # If enabled, enables the conversion function from Roman letters to hiragana. - japaneseConversionEnabled: false - # If enabled, the quick reply feature via the /reply command will be activated. - quickRepliesEnabled: true + quickReplies: + # If enabled, the quick reply feature via the /reply command will be activated. + enabled: true + japaneseConversion: + # If enabled, enables the conversion function from Roman letters to hiragana. + enabled: false + cache: + maxEntries: 500 + saveIntervalSeconds: 300 + filePath: "conversion_cache.json" + api: + timeout: 3000 + retryAttempts: 2 + settings: + directory: "settings" # ---------------------------------------------- # --------- Message Format Settings -------- diff --git a/platform-paper/src/main/resources/paper-plugin.yml b/platform-paper/src/main/resources/paper-plugin.yml index 7ad79d6..97b6358 100644 --- a/platform-paper/src/main/resources/paper-plugin.yml +++ b/platform-paper/src/main/resources/paper-plugin.yml @@ -13,6 +13,8 @@ permissions: default: true lunaticchat.command.reply: default: true + lunaticchat.command.jp: + default: true lunaticchat.spy: default: op |
