summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-01-10 13:21:32 +0900
committerGitHub <noreply@github.com>2026-01-10 13:21:32 +0900
commit7f8cc94aaa28340f084f1b700fd07295757df76a (patch)
tree8b4e956cfb2eebfa4286e79de6dcfe6978d1bf88
parentb1c44dc64111710d4197d34e17e912e4f291602e (diff)
parent1aec4ff5aad3a1b25bf7cf904c2eb44cc173ae98 (diff)
downloadLunaticChat-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
-rw-r--r--CLAUDE.md196
-rw-r--r--engine/build.gradle.kts4
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/exception/ConversionCacheFileNotFoundException.kt7
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt2
-rw-r--r--platform-paper/build.gradle.kts7
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt71
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/handler/DirectMessageHandler.kt39
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt9
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/RomajiConvertToggleCommand.kt89
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt10
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt35
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/FeaturesConfig.kt4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/JapaneseConversionFeatureConfig.kt11
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/QuickRepliesFeatureConfig.kt5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/CacheData.kt9
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt129
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/GoogleIMEClient.kt56
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/KanaConverter.kt297
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt42
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt35
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt18
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerChatSettings.kt17
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt148
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/UUIDSerializer.kt27
-rw-r--r--platform-paper/src/main/resources/config.yml19
-rw-r--r--platform-paper/src/main/resources/paper-plugin.yml2
26 files changed, 1230 insertions, 58 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 20e1061..fd482ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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