summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-08-05 01:04:51 +0900
committerGitHub <noreply@github.com>2026-08-05 01:04:51 +0900
commit27457cdb5f95c9fcb70a455034410097e30a30d6 (patch)
tree146936a86d8488ac5b078238d24f1f56e8cd38be
parent9a57b249dd660c49ac968ab2d3246736772504e1 (diff)
parent6e8b634ff0f7197e259cd899e97a1d363e0b62e6 (diff)
downloadLunaticChat-27457cdb5f95c9fcb70a455034410097e30a30d6.tar.gz
LunaticChat-27457cdb5f95c9fcb70a455034410097e30a30d6.tar.bz2
LunaticChat-27457cdb5f95c9fcb70a455034410097e30a30d6.zip
Merge pull request #261 from m1sk9/perf/hot-path-and-startup
perf: take chat and command hot paths off the tick thread
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt34
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt19
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt77
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt13
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt54
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt76
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt20
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt27
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt20
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt54
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/ReplyCommand.kt13
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommand.kt13
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt24
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomajiConversionHelper.kt35
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt98
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManager.kt7
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerPresenceListener.kt5
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt25
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManager.kt14
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt7
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt8
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt20
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt76
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt152
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt15
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManagerTest.kt36
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt25
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt121
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt23
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt60
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt135
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt39
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManagerTest.kt16
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt11
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt13
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt9
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt10
-rw-r--r--platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt8
-rw-r--r--platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelayTest.kt20
40 files changed, 1212 insertions, 224 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt
new file mode 100644
index 0000000..c3cd28b
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWrite.kt
@@ -0,0 +1,34 @@
+package dev.m1sk9.lunaticChat.paper
+
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import kotlin.io.path.deleteIfExists
+import kotlin.io.path.writeText
+
+/**
+ * Replaces the file at this path with [content], so nothing ever reads a half-written file.
+ *
+ * Bukkit runs onDisable before cancelling scheduler tasks, so a shutdown save and a still-pending
+ * debounced save can reach the same file at once. The temporary file gets a unique name for that
+ * reason: a fixed sibling would only move the interleaving from the destination to the temporary
+ * file, and the losing move would then fail with it already gone.
+ */
+fun Path.writeTextAtomically(content: String) {
+ val temporaryFile = Files.createTempFile(parent, fileName.toString(), ".tmp")
+ try {
+ temporaryFile.writeText(content)
+ try {
+ Files.move(temporaryFile, this, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
+ } catch (_: AtomicMoveNotSupportedException) {
+ // Network-mounted data directories (NFS, SMB) can refuse an atomic rename. A plain
+ // replace is still better than writing the destination in place, since the content is
+ // already complete by the time anything lands on top of it.
+ Files.move(temporaryFile, this, StandardCopyOption.REPLACE_EXISTING)
+ }
+ } catch (e: Throwable) {
+ temporaryFile.deleteIfExists()
+ throw e
+ }
+}
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 5b8cab1..f39e5d4 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
@@ -40,11 +40,22 @@ class LunaticChat :
private lateinit var services: ServiceContainer
private lateinit var configuration: LunaticChatConfiguration
private lateinit var serviceInitializer: ServiceInitializer
- private lateinit var pluginScope: PluginCoroutineScope
+
+ // Read by commands that must not block the tick thread.
+ lateinit var pluginScope: PluginCoroutineScope
+ private set
+
+ /** Serializes each player's outgoing messages so they arrive in the order they were sent. */
+ lateinit var deliveryQueue: PerPlayerWorkQueue
+ private set
private var updateChecker: UpdateChecker? = null
private val updateAvailable = AtomicBoolean(false)
+ // Only the Japanese conversion and update-check features make HTTP calls, and both default
+ // to off, so a stock install should not pay for a CIO engine and its thread pool.
+ private val httpClient = lazy { HttpClient(CIO) }
+
override fun onEnable() {
saveDefaultConfig()
val configManager = ConfigManager()
@@ -55,10 +66,9 @@ class LunaticChat :
logger.info("Debug: $configuration")
}
- val httpClient = HttpClient(CIO)
-
// Initialize plugin coroutine scope
pluginScope = PluginCoroutineScope(logger)
+ deliveryQueue = PerPlayerWorkQueue(pluginScope.scope, logger)
// Initialize all services
serviceInitializer =
@@ -79,7 +89,7 @@ class LunaticChat :
// Check for updates
if (configuration.checkForUpdates) {
- initializeUpdateChecker(httpClient)
+ initializeUpdateChecker(httpClient.value)
}
logger.info("LunaticChat enabled.")
@@ -88,6 +98,7 @@ class LunaticChat :
override fun onDisable() {
pluginScope.cancel()
serviceInitializer.shutdown(services)
+ if (httpClient.isInitialized()) httpClient.value.close()
logger.info("LunaticChat disabled.")
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt
new file mode 100644
index 0000000..f5e4a28
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueue.kt
@@ -0,0 +1,77 @@
+package dev.m1sk9.lunaticChat.paper
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.logging.Level
+import java.util.logging.Logger
+
+/**
+ * Runs work submitted for a player one item at a time, in the order it was submitted.
+ *
+ * Launching a coroutine per message would let a fast one overtake a slow one - a cached romaji
+ * conversion finishing ahead of an uncached one sent before it - so a player's messages could
+ * appear out of order to themselves and to their recipient. A queue per player keeps each player's
+ * messages ordered while still letting different players proceed independently.
+ *
+ * [submit] must be called from one thread per player (the server's command thread), since that is
+ * what makes "the order it was submitted" well defined.
+ */
+class PerPlayerWorkQueue(
+ private val scope: CoroutineScope,
+ private val logger: Logger,
+) {
+ private val queues = ConcurrentHashMap<UUID, SendChannel<suspend () -> Unit>>()
+
+ /**
+ * Queues [work] behind anything already pending for [playerId].
+ */
+ fun submit(
+ playerId: UUID,
+ work: suspend () -> Unit,
+ ) {
+ // Checked rather than left to trySend: cancelling the scope kills the worker coroutines but
+ // does not close their channels, so after shutdown trySend would keep reporting success for
+ // work nothing will ever read.
+ val accepted = scope.isActive && queues.computeIfAbsent(playerId) { startWorker() }.trySend(work).isSuccess
+ if (!accepted) {
+ // Reachable once the scope is cancelled at shutdown, or if the player's queue is
+ // released in the same tick as their command. Dropping a message in silence is worse
+ // than saying so.
+ logger.warning("Discarded queued work for player $playerId: their queue is closed")
+ }
+ }
+
+ /**
+ * Drops the player's queue once they can no longer send anything. Work already queued still
+ * runs; without this the map and its worker coroutines would grow for the life of the server.
+ */
+ fun release(playerId: UUID) {
+ queues.remove(playerId)?.close()
+ }
+
+ private fun startWorker(): SendChannel<suspend () -> Unit> {
+ // Unlimited so that submit never suspends or drops work on the caller's thread.
+ val channel = Channel<suspend () -> Unit>(Channel.UNLIMITED)
+ scope.launch {
+ for (work in channel) {
+ // One failed message must not end the loop. If it did, the channel would stay in
+ // `queues` with nothing reading it, so every later message from this player would
+ // be buffered and never delivered - with no way to recover short of reconnecting.
+ try {
+ work()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ logger.log(Level.SEVERE, "Queued work failed", e)
+ }
+ }
+ }
+ return channel
+ }
+}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt
index f67d262..22ff0dc 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/PluginCoroutineScope.kt
@@ -1,9 +1,11 @@
package dev.m1sk9.lunaticChat.paper
+import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import java.util.logging.Level
import java.util.logging.Logger
/**
@@ -28,7 +30,16 @@ class PluginCoroutineScope(
private val logger: Logger,
) {
private val job = SupervisorJob()
- val scope = CoroutineScope(Dispatchers.Default + job)
+
+ // Without this, a coroutine that throws reports to the JVM default handler and never reaches
+ // the plugin's log - and callers that dispatch work and return immediately have no other way
+ // to learn that it failed.
+ private val errorHandler =
+ CoroutineExceptionHandler { _, throwable ->
+ logger.log(Level.SEVERE, "Unhandled exception in a plugin coroutine", throwable)
+ }
+
+ val scope = CoroutineScope(Dispatchers.Default + job + errorHandler)
/**
* Cancels all coroutines in this scope.
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
index c118c12..5ec2e0a 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt
@@ -25,6 +25,7 @@ import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.plugin.java.JavaPlugin
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
+import java.util.logging.Level
import java.util.logging.Logger
import kotlin.time.Duration.Companion.milliseconds
@@ -48,11 +49,16 @@ private data class ChannelComponents(
class ServiceInitializer(
private val plugin: JavaPlugin,
private val configuration: LunaticChatConfiguration,
- private val httpClient: HttpClient,
+ private val httpClient: Lazy<HttpClient>,
private val logger: Logger,
) {
private val handshakeCompleted = AtomicBoolean(false)
+ private companion object {
+ /** Matches the value documented in config.yml. */
+ const val DEFAULT_CACHE_SAVE_INTERVAL_SECONDS = 300L
+ }
+
/**
* Initializes all services in dependency order.
*
@@ -186,7 +192,6 @@ class ServiceInitializer(
ConversionCache(
cacheFile = plugin.dataFolder.resolve(configuration.features.japaneseConversion.cacheFilePath).toPath(),
maxEntries = configuration.features.japaneseConversion.cacheMaxEntries,
- saver = DebouncedSaver(plugin),
logger = logger,
)
cache.loadFromDisk()
@@ -195,7 +200,7 @@ class ServiceInitializer(
val apiClient =
GoogleIMEClient(
timeout = configuration.features.japaneseConversion.apiTimeout.milliseconds,
- httpClient = httpClient,
+ httpClient = httpClient.value,
)
// Initialize Romanji converter
@@ -222,7 +227,7 @@ class ServiceInitializer(
val storage =
ChannelStorage(
channelsFile = channelsFile,
- plugin = plugin,
+ saver = DebouncedSaver(plugin),
logger = logger,
)
@@ -419,10 +424,21 @@ class ServiceInitializer(
fun schedulePeriodicTasks(services: ServiceContainer) {
val conversionCache = services.conversionCache
if (conversionCache != null) {
+ // The periodic task is the only writer besides shutdown, so a non-positive interval
+ // would both be rejected by runAtFixedRate and leave the cache unsaved until the
+ // server stopped. Fall back to the documented default rather than to one second,
+ // which would rewrite the whole cache file every tick anyone chatted.
+ val configuredInterval = configuration.features.japaneseConversion.cacheSaveIntervalSeconds
val intervalSeconds =
- configuration.features.japaneseConversion
- .cacheSaveIntervalSeconds
- .toLong()
+ if (configuredInterval > 0) {
+ configuredInterval.toLong()
+ } else {
+ logger.warning(
+ "features.japaneseConversion.cache.saveIntervalSeconds must be positive; " +
+ "using $DEFAULT_CACHE_SAVE_INTERVAL_SECONDS seconds instead of $configuredInterval",
+ )
+ DEFAULT_CACHE_SAVE_INTERVAL_SECONDS
+ }
plugin.server.asyncScheduler.runAtFixedRate(
plugin,
{ conversionCache.saveToDisk() },
@@ -437,10 +453,24 @@ class ServiceInitializer(
* Performs shutdown tasks, including saving all caches to disk.
*/
fun shutdown(services: ServiceContainer) {
- services.playerSettingsManager.saveToDisk()
- services.conversionCache?.saveToDisk()
- services.channelManager?.saveToDisk()
- services.channelMessageLogger?.shutdown()
- services.velocityConnectionManager?.shutdown()
+ shutdownStep("save player settings") { services.playerSettingsManager.saveToDisk() }
+ shutdownStep("save the conversion cache") { services.conversionCache?.saveToDisk() }
+ shutdownStep("save channel data") { services.channelManager?.saveToDisk() }
+ shutdownStep("shut down the channel message logger") { services.channelMessageLogger?.shutdown() }
+ shutdownStep("shut down the Velocity connection") { services.velocityConnectionManager?.shutdown() }
+ }
+
+ // The steps are independent, so one that throws must not skip the ones after it - which is what
+ // an exception escaping onDisable would do, leaving the log flusher and the Velocity connection
+ // to be torn down by the server instead.
+ private fun shutdownStep(
+ what: String,
+ step: () -> Unit,
+ ) {
+ try {
+ step()
+ } catch (e: Exception) {
+ logger.log(Level.SEVERE, "Failed to $what during shutdown", e)
+ }
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt
index 47780fb..663984c 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManager.kt
@@ -29,6 +29,12 @@ class ChannelManager(
private val membersCache = ConcurrentHashMap<String, CopyOnWriteArrayList<ChannelMember>>()
private val activeChannels = ConcurrentHashMap<UUID, String>()
+ // Handed to the debounced write, which reads it when it finally runs rather than when it was
+ // queued - so a batched write persists every change made during the delay, not just the one
+ // that started it.
+ @Volatile
+ private var latestSnapshot: ChannelData = ChannelData()
+
/**
* Initializes the ChannelManager by loading data from storage.
*/
@@ -168,6 +174,44 @@ class ChannelManager(
}
/**
+ * Answers whether a player belongs to a channel without handing out the member list.
+ *
+ * [getChannelMembers] copies the list defensively, which is the wrong price to pay for a
+ * question that only needs to scan it.
+ *
+ * @param channelId The ID of the channel.
+ * @param playerId The UUID of the player.
+ * @return Result containing true if the player is a member.
+ * @throws ChannelNotFoundException if the channel does not exist.
+ */
+ fun isMember(
+ channelId: String,
+ playerId: UUID,
+ ): Result<Boolean> {
+ channelsCache[channelId]
+ ?: return Result.failure(ChannelNotFoundException(channelId))
+
+ val members = membersCache[channelId] ?: return Result.success(false)
+ return Result.success(members.any { it.playerId == playerId })
+ }
+
+ /**
+ * Returns the ids of every existing channel the player belongs to.
+ *
+ * Walks the membership lists once in place; asking per channel meant copying every channel's
+ * member list to answer a question about one player.
+ *
+ * @param playerId The UUID of the player.
+ */
+ fun channelIdsOf(playerId: UUID): List<String> =
+ membersCache
+ .asSequence()
+ .filter { (channelId, members) ->
+ channelsCache.containsKey(channelId) && members.any { it.playerId == playerId }
+ }.map { it.key }
+ .toList()
+
+ /**
* Adds a member to a channel.
*
* @param channelId The ID of the channel.
@@ -396,28 +440,32 @@ class ChannelManager(
* Saves the current state of channels and members to storage asynchronously.
*/
private fun saveToStorage() {
- val data =
- ChannelData(
- channels = channelsCache.toMap(),
- members = membersCache.mapValues { it.value.toList() },
- activeChannels = activeChannels.mapKeys { it.key.toString() },
- )
- storage.queueAsyncSave(data)
+ latestSnapshot = snapshot()
+ storage.queueAsyncSave { latestSnapshot }
logger.fine("${channelsCache.size} channels queued for saving to storage.")
}
/**
+ * A point-in-time copy of everything persisted.
+ *
+ * Taken on the mutating thread, because the three caches are separate: read from the write
+ * thread instead, a snapshot could catch a channel already removed from [channelsCache] while
+ * its [membersCache] entry still existed, and persist the halves inconsistently. Copying the
+ * caches is cheap; it is the file write that the debounce is there to coalesce.
+ */
+ private fun snapshot(): ChannelData =
+ ChannelData(
+ channels = channelsCache.toMap(),
+ members = membersCache.mapValues { it.value.toList() },
+ activeChannels = activeChannels.mapKeys { it.key.toString() },
+ )
+
+ /**
* Saves the current state of channels and members to storage synchronously.
* Should only br called during server shutdown.
*/
fun saveToDisk() {
- val data =
- ChannelData(
- channels = channelsCache.toMap(),
- members = membersCache.mapValues { it.value.toList() },
- activeChannels = activeChannels.mapKeys { it.key.toString() },
- )
- storage.saveToDisk(data)
+ storage.saveToDisk(snapshot())
}
/**
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt
index 3833376..6ce2672 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManager.kt
@@ -45,10 +45,7 @@ class ChannelMembershipManager(
fun isMember(
playerId: UUID,
channelId: String,
- ): Result<Boolean> =
- channelManager.getChannelMembers(channelId).map { members ->
- members.any { it.playerId == playerId }
- }
+ ): Result<Boolean> = channelManager.isMember(channelId, playerId)
/**
* Gets the role of a member in a channel.
@@ -338,18 +335,5 @@ class ChannelMembershipManager(
* @param playerId The UUID of the player.
* @return Result containing a list of channel IDs where the player is a member.
*/
- fun getPlayerChannels(playerId: UUID): Result<List<String>> {
- val allChannels =
- channelManager.getAllChannels().getOrElse {
- return Result.failure(it)
- }
-
- val playerChannels =
- allChannels
- .filter { channel ->
- isMember(playerId, channel.id).getOrElse { false }
- }.map { it.id }
-
- return Result.success(playerChannels)
- }
+ fun getPlayerChannels(playerId: UUID): Result<List<String>> = Result.success(channelManager.channelIdsOf(playerId))
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt
index 7a0c3c8..4fb18e8 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorage.kt
@@ -3,24 +3,24 @@ package dev.m1sk9.lunaticChat.paper.chat.channel
import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelData
import dev.m1sk9.lunaticChat.engine.exception.ChannelStorageLoadException
import dev.m1sk9.lunaticChat.engine.exception.ChannelStorageSaveException
+import dev.m1sk9.lunaticChat.paper.DebouncedSaver
+import dev.m1sk9.lunaticChat.paper.writeTextAtomically
import kotlinx.serialization.json.Json
-import org.bukkit.plugin.java.JavaPlugin
import java.nio.file.Path
import java.util.logging.Logger
import kotlin.io.path.bufferedReader
import kotlin.io.path.exists
-import kotlin.io.path.writeText
/**
* Manages the storage of channel data on disk.
*
* @property channelsFile The path to the file where channel data is stored.
- * @property plugin The JavaPlugin instance for accessing plugin resources.
+ * @property saver Coalesces bursts of save requests into one asynchronous write.
* @property logger The logger for logging messages.
*/
class ChannelStorage(
private val channelsFile: Path,
- private val plugin: JavaPlugin,
+ private val saver: DebouncedSaver,
private val logger: Logger,
) {
private val json =
@@ -66,9 +66,8 @@ class ChannelStorage(
fun saveToDisk(data: ChannelData) {
try {
val jsonContent = json.encodeToString(ChannelData.serializer(), data)
- channelsFile.writeText(jsonContent).also {
- logger.fine("Successfully saved channels from ${channelsFile.fileName}.")
- }
+ channelsFile.writeTextAtomically(jsonContent)
+ logger.fine("Successfully saved channels from ${channelsFile.fileName}.")
} catch (e: Exception) {
throw ChannelStorageSaveException(
"Failed to save channels to ${channelsFile.fileName}: ${e.message}",
@@ -78,18 +77,18 @@ class ChannelStorage(
}
/**
- * Queues an asynchronous save of channel data to disk.
+ * Queues a debounced asynchronous save of channel data to disk.
*
- * @param data The ChannelData to save.
- * @throws ChannelStorageSaveException if there is an error saving the data.
+ * @param data Supplies the channel data to write. It is called when the write runs rather
+ * than when it is queued, so a burst of channel changes costs one snapshot and one file
+ * write instead of one of each per change.
*/
- fun queueAsyncSave(data: ChannelData) {
- plugin.server.asyncScheduler.runNow(plugin) {
+ fun queueAsyncSave(data: () -> ChannelData) {
+ saver.request {
try {
- saveToDisk(data)
+ saveToDisk(data())
} catch (e: ChannelStorageSaveException) {
logger.severe("Error saving channel data asynchronously: ${e.message}")
- e.printStackTrace()
}
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
index 985f321..4da0c1a 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
@@ -12,7 +12,6 @@ import dev.m1sk9.lunaticChat.paper.i18n.withChatPlaceholders
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
import io.ktor.util.logging.Logger
import net.kyori.adventure.text.Component
-import net.kyori.adventure.text.event.HoverEvent
import org.bukkit.Bukkit
import org.bukkit.entity.Player
@@ -44,19 +43,10 @@ class ChannelMessageHandler(
// Send to spy players (exclude sender and channel members)
val memberIds = context.members.map { it.playerId }.toSet()
- SpyPermissionManager
- .getDirectMessageSpyPlayers()
- .values
- .filter { it.isOnline && it.uniqueId != playerId && it.uniqueId !in memberIds }
- .forEach {
- it.sendMessage(
- formattedMessage.hoverEvent(
- HoverEvent.showText(
- Component.text(languageManager.getMessage("general.spyMessage")),
- ),
- ),
- )
- }
+ SpyPermissionManager.notifySpies(
+ noticeText = languageManager.getMessage("general.spyMessage"),
+ exclude = { it.uniqueId == playerId || it.uniqueId in memberIds },
+ ) { formattedMessage }
context.members.forEach { member ->
Bukkit.getPlayer(member.playerId)?.let { memberPlayer ->
if (memberPlayer.isOnline) {
@@ -75,7 +65,7 @@ class ChannelMessageHandler(
}
}
- logger.info("Channel Message from ${player.name} in ${context.channel.name}: $message")
+ logger.debug("Channel Message from {} in {}: {}", player.name, context.channel.name, message)
// Log message to file if logging is enabled
messageLogger?.let {
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
index c3fc923..2033661 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandler.kt
@@ -12,7 +12,6 @@ import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
import dev.m1sk9.lunaticChat.paper.velocity.RemotePlayerRegistry
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.event.ClickEvent
-import net.kyori.adventure.text.event.HoverEvent
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import java.util.UUID
@@ -67,6 +66,22 @@ class DirectMessageHandler(
}
/**
+ * Records that [sender] messaged a player on another server, so /reply can find them.
+ *
+ * Separate from the delivery itself because delivery is queued: /reply reads the target on the
+ * command thread, so recording it only once the message has been converted and sent would
+ * leave a window - as long as the conversion timeout - where /reply says there is nobody to
+ * reply to.
+ */
+ fun recordRemoteRecipient(
+ sender: Player,
+ targetName: String,
+ targetServerName: String,
+ ) {
+ lastRecipient[sender.uniqueId] = ReplyTarget.Remote(targetName, targetServerName)
+ }
+
+ /**
* Gets the target to reply to.
* First checks if someone has messaged this player, otherwise falls back
* to the last person they messaged. Targets that are no longer reachable
@@ -103,18 +118,19 @@ class DirectMessageHandler(
/**
* Sends a direct message from one player to another on the same server.
- * Handles formatting and recording the conversation.
- * Applies romaji-to-Japanese conversion if sender has it enabled.
+ * Handles formatting, and applies romaji-to-Japanese conversion if sender has it enabled.
+ *
+ * The conversation is recorded by the caller via [recordMessage] before the delivery is queued,
+ * for the same reason as [recordRemoteRecipient]. Recording it here as well would also re-insert
+ * entries that [clearPlayer] has already swept, if the recipient quits mid-delivery.
*
* @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 recipientSettings = settingsManager?.getSettings(recipient.uniqueId)
@@ -146,7 +162,7 @@ class DirectMessageHandler(
* @return the message body to relay (romaji-converted if applicable), since the
* receiving server has no access to the sender's settings.
*/
- fun handleOutgoingCrossServerMessage(
+ suspend fun handleOutgoingCrossServerMessage(
sender: Player,
targetName: String,
targetServerName: String,
@@ -168,7 +184,7 @@ class DirectMessageHandler(
?.playMessageSendNotification()
}
- lastRecipient[sender.uniqueId] = ReplyTarget.Remote(targetName, targetServerName)
+ recordRemoteRecipient(sender, targetName, targetServerName)
return displayMessage
}
@@ -197,7 +213,7 @@ class DirectMessageHandler(
lastMessager[recipient.uniqueId] = ReplyTarget.Remote(senderName, sourceServerName)
}
- private fun convertIfEnabled(
+ private suspend fun convertIfEnabled(
message: String,
enabled: Boolean,
): String =
@@ -213,20 +229,12 @@ class DirectMessageHandler(
recipientName: String,
rawMessage: String,
) {
- val spyMessage = formatMessage(format, senderName, recipientName, rawMessage, replyTo = senderName)
- SpyPermissionManager
- .getDirectMessageSpyPlayers()
- .values
- .filter { it.isOnline && it.name !in setOf(senderName, recipientName) }
- .forEach {
- it.sendMessage(
- spyMessage.hoverEvent(
- HoverEvent.showText(
- Component.text(languageManager.getMessage("general.spyMessage")),
- ),
- ),
- )
- }
+ SpyPermissionManager.notifySpies(
+ noticeText = languageManager.getMessage("general.spyMessage"),
+ exclude = { it.name == senderName || it.name == recipientName },
+ ) {
+ formatMessage(format, senderName, recipientName, rawMessage, replyTo = senderName)
+ }
}
private fun formatMessage(
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 86cac38..df5b2a9 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
@@ -5,6 +5,7 @@ import com.mojang.brigadier.builder.LiteralArgumentBuilder
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.PerPlayerWorkQueue
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.chat.handler.ReplyTarget
import dev.m1sk9.lunaticChat.paper.command.annotation.Command
@@ -30,6 +31,10 @@ class ReplyCommand(
private val dmHandler: DirectMessageHandler,
override val languageManager: LanguageManager,
private val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
+ // Delivery is queued rather than run inline: romaji conversion can reach the Google IME API,
+ // and a command executor runs on the tick thread. Queueing per sender keeps their messages in
+ // the order they typed them.
+ private val deliveryQueue: PerPlayerWorkQueue = plugin.deliveryQueue,
) : LunaticCommand(plugin) {
override val description: String
get() = languageManager.getMessage("commandDescription.reply")
@@ -65,14 +70,18 @@ class ReplyCommand(
val recipient =
Bukkit.getPlayer(target.uuid)
?: return fail("directMessage.replyTargetNotFound")
- dmHandler.sendDirectMessage(sender, recipient, message)
+ dmHandler.recordMessage(sender, recipient)
+ deliveryQueue.submit(sender.uniqueId) { dmHandler.sendDirectMessage(sender, recipient, message) }
CommandResult.Success
}
is ReplyTarget.Remote -> {
val manager =
crossServerDirectMessageManager
?: return fail("directMessage.replyTargetNotFound")
- manager.sendCrossServerMessage(sender, target.playerName, target.serverName, message)
+ dmHandler.recordRemoteRecipient(sender, target.playerName, target.serverName)
+ deliveryQueue.submit(sender.uniqueId) {
+ manager.sendCrossServerMessage(sender, target.playerName, target.serverName, message)
+ }
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 990399e..5e6e2c7 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
@@ -7,6 +7,7 @@ import com.mojang.brigadier.suggestion.SuggestionsBuilder
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.PerPlayerWorkQueue
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.command.annotation.Command
import dev.m1sk9.lunaticChat.paper.command.annotation.Permission
@@ -37,6 +38,10 @@ class TellCommand(
private val crossServerDirectMessageManager: CrossServerDirectMessageManager? = null,
private val remotePlayerRegistry: RemotePlayerRegistry? = null,
private val localServerName: String = "",
+ // Delivery is queued rather than run inline: romaji conversion can reach the Google IME API,
+ // and a command executor runs on the tick thread. Queueing per sender keeps their messages in
+ // the order they typed them.
+ private val deliveryQueue: PerPlayerWorkQueue = plugin.deliveryQueue,
) : LunaticCommand(plugin) {
override val description: String
get() = languageManager.getMessage("commandDescription.tell")
@@ -99,7 +104,10 @@ class TellCommand(
) {
return fail("directMessage.yourself")
}
- manager.sendCrossServerMessage(sender, name, server, message)
+ // Recorded here rather than inside the queued work: /reply reads the target on this
+ // thread, so it must be visible as soon as /tell returns.
+ directMessageHandler.recordRemoteRecipient(sender, name, server)
+ deliveryQueue.submit(sender.uniqueId) { manager.sendCrossServerMessage(sender, name, server, message) }
return CommandResult.Success
}
@@ -111,7 +119,8 @@ class TellCommand(
return fail("directMessage.yourself")
}
- directMessageHandler.sendDirectMessage(sender, recipient, message)
+ directMessageHandler.recordMessage(sender, recipient)
+ deliveryQueue.submit(sender.uniqueId) { directMessageHandler.sendDirectMessage(sender, recipient, message) }
return CommandResult.Success
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
index ad0a9e0..c550f09 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCache.kt
@@ -1,22 +1,22 @@
package dev.m1sk9.lunaticChat.paper.converter
import dev.m1sk9.lunaticChat.engine.converter.CacheData
-import dev.m1sk9.lunaticChat.paper.DebouncedSaver
+import dev.m1sk9.lunaticChat.paper.writeTextAtomically
import kotlinx.serialization.json.Json
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 saver: DebouncedSaver,
private val logger: Logger,
) {
private val conversionMemoryCache = ConcurrentHashMap<String, String>()
+ private val dirty = AtomicBoolean(false)
companion object {
private const val CACHE_VERSION = "1"
@@ -55,7 +55,7 @@ class ConversionCache(
private fun initializeEmptyCache() {
val emptyData = CacheData(version = CACHE_VERSION, entries = emptyMap())
val jsonBuffer = Json.encodeToString(CacheData.serializer(), emptyData)
- cacheFile.writeText(jsonBuffer)
+ cacheFile.writeTextAtomically(jsonBuffer)
}
/**
@@ -81,16 +81,21 @@ class ConversionCache(
}
conversionMemoryCache[key] = value
- saver.request(::saveToDisk)
+ dirty.set(true)
}
/**
- * Saves the conversion cache from memory to disk.
- * This operation is performed asynchronously.
+ * Writes the cache to disk if anything changed since the last write.
*
- * @throws Exception if an error occurs during the save operation.
+ * Called from the periodic task and at shutdown. Skipping a clean cache matters because the
+ * task fires on a fixed interval whether or not anyone chatted.
+ *
+ * A failed write leaves the previous file untouched: a cache that does not parse is discarded
+ * wholesale on the next boot, so a torn file costs every entry accumulated so far.
*/
fun saveToDisk() {
+ if (!dirty.getAndSet(false)) return
+
try {
val data =
CacheData(
@@ -98,9 +103,10 @@ class ConversionCache(
entries = conversionMemoryCache.toMap(),
)
val jsonBuffer = Json.encodeToString(CacheData.serializer(), data)
- cacheFile.writeText(jsonBuffer)
+ cacheFile.writeTextAtomically(jsonBuffer)
logger.info("Saved ${conversionMemoryCache.size} cache entries to disk.")
} catch (e: Exception) {
+ dirty.set(true)
logger.severe("Failed to save conversion cache to disk: ${e.message}")
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomajiConversionHelper.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomajiConversionHelper.kt
index 841061a..4da017f 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomajiConversionHelper.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomajiConversionHelper.kt
@@ -1,5 +1,6 @@
package dev.m1sk9.lunaticChat.paper.converter
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
@@ -12,15 +13,33 @@ import kotlinx.coroutines.withTimeoutOrNull
* @param timeoutMs Timeout in milliseconds for the conversion (default: 1000ms).
* @return The message with conversion appended (e.g., "hello §e(こんにちは)"), or the original message.
*/
-fun convertWithRomaji(
+suspend fun convertWithRomaji(
message: String,
converter: RomanjiConverter,
timeoutMs: Long = 1000,
): String =
- runCatching {
- runBlocking {
- withTimeoutOrNull(timeoutMs) {
- converter.convert(message)
- }?.let { "$message §e($it)" } ?: message
- }
- }.getOrElse { message }
+ try {
+ withTimeoutOrNull(timeoutMs) {
+ converter.convert(message)
+ }?.let { "$message §e($it)" } ?: message
+ } catch (e: CancellationException) {
+ // Rethrown rather than degraded to the original message: this runs on the delivery queue, so
+ // swallowing it would let a message be delivered after the plugin scope has been cancelled.
+ // The conversion's own timeout is handled by withTimeoutOrNull and does not reach here.
+ throw e
+ } catch (_: Exception) {
+ message
+ }
+
+/**
+ * Blocking form of [convertWithRomaji], for callers that cannot suspend.
+ *
+ * AsyncChatEvent is the only such caller: it has to decide whether to cancel the event and what
+ * body to set before the handler returns, and it already runs off the tick thread. Command
+ * handlers do run on the tick thread and must use the suspending form instead.
+ */
+fun convertWithRomajiBlocking(
+ message: String,
+ converter: RomanjiConverter,
+ timeoutMs: Long = 1000,
+): String = runBlocking { convertWithRomaji(message, converter, timeoutMs) }
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
index c961af8..530d3dc 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverter.kt
@@ -2,6 +2,12 @@ package dev.m1sk9.lunaticChat.paper.converter
import dev.m1sk9.lunaticChat.engine.converter.GoogleIMEClient
import dev.m1sk9.lunaticChat.engine.converter.KanaConverter
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
import java.util.logging.Logger
class RomanjiConverter(
@@ -9,7 +15,13 @@ class RomanjiConverter(
private val apiClient: GoogleIMEClient,
private val logger: Logger,
private val debugMode: Boolean = false,
+ maxConcurrentRequests: Int = 4,
) {
+ // A long message would otherwise open one request per word at once. Google IME answering with
+ // a rate limit lands in convertWord's catch and degrades silently to hiragana, so it is better
+ // not to ask that hard in the first place.
+ private val limiter = Semaphore(maxConcurrentRequests)
+
/**
* Converts the given romaji input to Japanese using the API client.
* Utilizes a word-level cache to store and retrieve previous conversion results.
@@ -30,52 +42,62 @@ class RomanjiConverter(
return null
}
- val words = input.split(" ")
- val results = mutableListOf<String>()
+ val words = input.split(" ").filter { it.isNotEmpty() }
- for (word in words) {
- if (word.isEmpty()) {
- continue
- }
+ // The words are independent, and callers convert under a timeout covering the whole
+ // message. Awaiting them one at a time makes an N-word message cost N round trips, so a
+ // long message runs out of budget after the first word or two.
+ //
+ // Converted once per distinct word: the sequential version got that for free because it
+ // cached each word before looking up the next, and a line that repeats a word should not
+ // ask the API twice for it.
+ val converted =
+ coroutineScope {
+ words
+ .distinct()
+ .map { word -> async { word to limiter.withPermit { convertWord(word) } } }
+ .awaitAll()
+ }.toMap()
- // Check cache first
- val cached = cache.get(word)
- if (cached != null) {
- if (debugMode) {
- logger.info("Cache hit for word: $word -> $cached")
- }
- results.add(cached)
- continue
- }
+ return words.joinToString(" ") { converted.getValue(it) }
+ }
- // Pre-validate: Check if the word is valid romaji before attempting conversion
- // This prevents partial conversion of English words (e.g., "This" -> "てぃs")
- if (!KanaConverter.isValidRomaji(word)) {
- if (debugMode) {
- logger.info("Word is not valid romaji, keeping original: $word")
- }
- results.add(word)
- continue
+ private suspend fun convertWord(word: String): String {
+ cache.get(word)?.let { cached ->
+ if (debugMode) {
+ logger.info("Cache hit for word: $word -> $cached")
}
+ return cached
+ }
- // Step 1: Romanji -> Hiragana
- val hiragana = KanaConverter.toHiragana(word)
+ // Pre-validate: Check if the word is valid romaji before attempting conversion
+ // This prevents partial conversion of English words (e.g., "This" -> "てぃs")
+ if (!KanaConverter.isValidRomaji(word)) {
+ if (debugMode) {
+ logger.info("Word is not valid romaji, keeping original: $word")
+ }
+ return word
+ }
- // Step 2: Hiragana -> Kanji/Kana
- val converted =
- try {
- apiClient.convert(hiragana)
- } catch (e: Exception) {
- logger.warning("Failed to convert $hiragana: ${e.message}")
- hiragana // Use hiragana if API fails
- }
+ // Step 1: Romanji -> Hiragana
+ val hiragana = KanaConverter.toHiragana(word)
- // Cache the word-level conversion
- cache.put(word, converted)
- results.add(converted)
- }
+ // Step 2: Hiragana -> Kanji/Kana
+ val converted =
+ try {
+ apiClient.convert(hiragana)
+ } catch (e: CancellationException) {
+ // Not an API failure: the caller's timeout fired. Caching the hiragana here would
+ // pin every word of the message to its unconverted form for good, because the words
+ // are converted concurrently and the timeout cancels all of them at once.
+ throw e
+ } catch (e: Exception) {
+ logger.warning("Failed to convert $hiragana: ${e.message}")
+ hiragana // Use hiragana if API fails
+ }
- return results.joinToString(" ")
+ cache.put(word, converted)
+ return converted
}
private fun isRomajiOnly(input: String): Boolean =
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManager.kt
index dd702ba..19774d2 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManager.kt
@@ -37,13 +37,16 @@ class LanguageManager(
private val languageCache = mutableMapOf<Language, Map<String, String>>()
/**
- * Initializes the language manager by loading all language files.
+ * Initializes the language manager by loading the languages [getMessage] can read: the
+ * selected one and the English fallback. Other bundled languages are never consulted, so
+ * parsing and flattening them at startup would be wasted work.
+ *
* This should be called during plugin initialization.
*
* @throws IllegalStateException if the English fallback file is missing or cannot be loaded
*/
fun initialize() {
- Language.entries.forEach { lang ->
+ linkedSetOf(Language.EN, selectedLanguage).forEach { lang ->
try {
val messages = loadLanguageFile(lang)
languageCache[lang] = messages
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
index 3bd2d4a..2652bed 100644
--- 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
@@ -4,7 +4,7 @@ import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager
import dev.m1sk9.lunaticChat.paper.chat.handler.ChannelMessageHandler
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter
-import dev.m1sk9.lunaticChat.paper.converter.convertWithRomaji
+import dev.m1sk9.lunaticChat.paper.converter.convertWithRomajiBlocking
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
import dev.m1sk9.lunaticChat.paper.velocity.CrossServerChatManager
import io.papermc.paper.event.player.AsyncChatEvent
@@ -74,7 +74,7 @@ class PlayerChatListener(
val displayMessage =
if (settings.japaneseConversionEnabled && romajiConverter != null) {
- convertWithRomaji(messageWithoutPrefix, romajiConverter)
+ convertWithRomajiBlocking(messageWithoutPrefix, romajiConverter)
} else {
messageWithoutPrefix
}
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 fc79ffe..1e3fde9 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
@@ -95,6 +95,9 @@ class PlayerPresenceListener(
channelManager?.setPlayerChannel(playerId, null)
// 3. Trigger async save of player settings
- playerSettingsManager.saveToDisk()
+ playerSettingsManager.queueSave()
+
+ // 4. Drop their delivery queue; anything already queued still runs
+ lunaticChat.deliveryQueue.release(playerId)
}
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
index 2c56219..2e50081 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/SpyPermissionManager.kt
@@ -1,6 +1,8 @@
package dev.m1sk9.lunaticChat.paper.common
import dev.m1sk9.lunaticChat.engine.permission.LunaticChatPermissionNode
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.event.HoverEvent
import org.bukkit.Bukkit
import org.bukkit.entity.Player
import org.bukkit.event.EventHandler
@@ -23,6 +25,29 @@ object SpyPermissionManager : Listener {
fun getDirectMessageSpyPlayers(): Map<UUID, Player> = directMessageSpyPlayers.toMap()
/**
+ * Sends a copy of a message to every online spy that [exclude] does not reject.
+ *
+ * [message] is only invoked when someone will actually read the result, and the "you are
+ * seeing this because you have spy permission" hover is built once for the whole audience
+ * rather than per recipient. Spies are rare, so both matter on the message path.
+ *
+ * @param noticeText Text shown on hover, explaining why the reader is seeing the message
+ * @param exclude Rejects players who are party to the message already
+ * @param message Builds the message body
+ */
+ fun notifySpies(
+ noticeText: String,
+ exclude: (Player) -> Boolean,
+ message: () -> Component,
+ ) {
+ val spies = directMessageSpyPlayers.values.filter { it.isOnline && !exclude(it) }
+ if (spies.isEmpty()) return
+
+ val withNotice = message().hoverEvent(HoverEvent.showText(Component.text(noticeText)))
+ spies.forEach { it.sendMessage(withNotice) }
+ }
+
+ /**
* Updates the cache of players with direct message spy permission.
* Call this on player join/quit/permission change events.
*/
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
index 9ee8d5a..110390d 100644
--- 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
@@ -66,8 +66,20 @@ class PlayerSettingsManager(
}
/**
+ * Queues a debounced asynchronous save without changing any setting.
+ *
+ * Used where the caller wants what is already in memory flushed soon - a player leaving, say -
+ * rather than paying for a write it does not need.
+ */
+ fun queueSave() {
+ storage.queueAsyncSave(::snapshot)
+ }
+
+ /**
* Forces an immediate synchronous save of all settings to disk.
- * This should only be called during plugin shutdown.
+ *
+ * Serializes every stored player and writes the whole file inline, so this belongs on the
+ * shutdown path only; everywhere else should use [queueSave] or [updateSettings].
*/
fun saveToDisk() {
storage.saveToDisk(snapshot())
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt
index f4d8a6f..6e28f6f 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/settings/YamlPlayerSettingsStorage.kt
@@ -3,11 +3,11 @@ package dev.m1sk9.lunaticChat.paper.settings
import com.charleskorn.kaml.Yaml
import dev.m1sk9.lunaticChat.engine.settings.PlayerSettingsData
import dev.m1sk9.lunaticChat.paper.DebouncedSaver
+import dev.m1sk9.lunaticChat.paper.writeTextAtomically
import java.nio.file.Path
import java.util.logging.Logger
import kotlin.io.path.bufferedReader
import kotlin.io.path.exists
-import kotlin.io.path.writeText
/**
* Handles YAML file I/O operations for player settings.
@@ -50,12 +50,15 @@ class YamlPlayerSettingsStorage(
* Saves player settings to the YAML file synchronously.
* This should only be called from async context or during shutdown.
*
+ * A failed write leaves the previous file untouched: loading falls back to empty settings when
+ * the YAML does not parse, so a torn file would silently discard every player's settings.
+ *
* @param data The settings data to save
*/
fun saveToDisk(data: PlayerSettingsData) {
try {
val yamlContent = yaml.encodeToString(PlayerSettingsData.serializer(), data)
- settingsFile.writeText(yamlContent)
+ settingsFile.writeTextAtomically(yamlContent)
logger.fine("Saved player settings to disk")
} catch (e: Exception) {
logger.severe("Failed to save settings: ${e.message}")
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt
index 2f2bde0..19668be 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt
@@ -69,7 +69,7 @@ class CrossServerChatManager(
PluginMessageChannel.ID,
PluginMessageCodec.encode(globalChatMessage),
)
- logger.info("Sent global chat message to Velocity: messageId=$messageId, player=$playerName")
+ logger.fine { "Sent global chat message to Velocity: messageId=$messageId, player=$playerName" }
} else {
logger.warning("Cannot send global chat message: player $playerId not found")
}
@@ -109,10 +109,10 @@ class CrossServerChatManager(
},
)
- logger.info(
+ logger.fine {
"Broadcasted global chat message from ${message.serverName}: " +
- "player=${message.playerName}, messageId=${message.messageId}",
- )
+ "player=${message.playerName}, messageId=${message.messageId}"
+ }
} catch (e: Exception) {
logger.log(Level.SEVERE, "Failed to handle incoming global chat message", e)
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
index b35f6a5..ccd8d40 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt
@@ -7,6 +7,7 @@ import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.i18n.MessageFormatter
+import kotlinx.coroutines.CancellationException
import org.bukkit.entity.Player
import org.bukkit.plugin.Plugin
import java.util.UUID
@@ -35,11 +36,12 @@ class CrossServerDirectMessageManager(
/**
* Sends a direct message to a player on another server through Velocity.
*
- * Must be called on the main server thread. The sender-side display, spy
- * notification and reply recording are handled by [DirectMessageHandler];
- * the (possibly romaji-converted) body is what gets relayed.
+ * Runs on the sender's delivery queue, off the tick thread, because the romaji conversion it
+ * goes through may wait on the Google IME API. The reply target is recorded by the command
+ * before the work is queued; the sender-side display and the spy notification are handled by
+ * [DirectMessageHandler], and the (possibly romaji-converted) body is what gets relayed.
*/
- fun sendCrossServerMessage(
+ suspend fun sendCrossServerMessage(
sender: Player,
targetName: String,
targetServerName: String,
@@ -69,10 +71,14 @@ class CrossServerDirectMessageManager(
)
sender.sendPluginMessage(plugin, PluginMessageChannel.ID, PluginMessageCodec.encode(relay))
- logger.info(
+ logger.fine {
"Sent direct message to Velocity: messageId=$messageId, " +
- "target=$targetName@$targetServerName",
- )
+ "target=$targetName@$targetServerName"
+ }
+ } catch (e: CancellationException) {
+ // Shutdown cancelling the delivery queue is not a delivery failure, and reporting it as
+ // SEVERE while carrying on past the cancellation would be wrong twice over.
+ throw e
} catch (e: Exception) {
logger.log(Level.SEVERE, "Failed to send cross-server direct message", e)
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt
new file mode 100644
index 0000000..ea0c35f
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/AtomicWriteTest.kt
@@ -0,0 +1,76 @@
+package dev.m1sk9.lunaticChat.paper
+
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.concurrent.ConcurrentLinkedQueue
+import java.util.concurrent.CyclicBarrier
+import kotlin.io.path.exists
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class AtomicWriteTest {
+ private fun withTemporaryDirectory(block: (Path) -> Unit) {
+ val directory = Files.createTempDirectory("atomic-write-test")
+ try {
+ block(directory)
+ } finally {
+ directory.toFile().deleteRecursively()
+ }
+ }
+
+ @Test
+ fun `writes the content and leaves no temporary file behind`() =
+ withTemporaryDirectory { directory ->
+ val target = directory.resolve("channels.json")
+
+ target.writeTextAtomically("""{"channels":[]}""")
+
+ assertEquals("""{"channels":[]}""", target.readText())
+ assertEquals(listOf(target), directory.listDirectoryEntries())
+ }
+
+ @Test
+ fun `replaces existing content`() =
+ withTemporaryDirectory { directory ->
+ val target = directory.resolve("settings.yml")
+ target.writeText("version: 1")
+
+ target.writeTextAtomically("version: 2")
+
+ assertEquals("version: 2", target.readText())
+ }
+
+ @Test
+ fun `concurrent writers each publish a whole file rather than colliding`() =
+ withTemporaryDirectory { directory ->
+ val target = directory.resolve("channels.json")
+ val writerCount = 8
+ val contents = (1..writerCount).map { "content-$it".repeat(4_000) }
+ val failures = ConcurrentLinkedQueue<Throwable>()
+ val barrier = CyclicBarrier(writerCount)
+
+ // A shutdown save and a still-pending debounced save can reach the same file at once.
+ // With a shared temporary path they interleave there instead, and the losing move fails
+ // with the temporary file already gone.
+ val writers =
+ contents.map { content ->
+ Thread {
+ barrier.await()
+ runCatching { target.writeTextAtomically(content) }
+ .onFailure { failures.add(it) }
+ }
+ }
+ writers.forEach { it.start() }
+ writers.forEach { it.join() }
+
+ assertTrue(failures.isEmpty(), "writes failed: ${failures.map { it.toString() }}")
+ assertContains(contents, target.readText())
+ assertEquals(listOf(target), directory.listDirectoryEntries())
+ assertTrue(target.exists())
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt
new file mode 100644
index 0000000..2d96cba
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/PerPlayerWorkQueueTest.kt
@@ -0,0 +1,152 @@
+package dev.m1sk9.lunaticChat.paper
+
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import java.util.UUID
+import java.util.concurrent.ConcurrentLinkedQueue
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class PerPlayerWorkQueueTest {
+ private val alice = UUID.fromString("00000001-0000-0000-0000-000000000000")
+ private val bob = UUID.fromString("00000002-0000-0000-0000-000000000000")
+
+ private suspend fun awaitSize(
+ completed: ConcurrentLinkedQueue<String>,
+ expected: Int,
+ ) = withTimeout(5_000) {
+ while (completed.size < expected) {
+ delay(5)
+ }
+ }
+
+ @Test
+ fun `a player's work runs in submission order even when later work is faster`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val completed = ConcurrentLinkedQueue<String>()
+
+ // The regression this guards: a cached conversion overtaking an uncached one sent
+ // before it, so the player sees their messages out of order.
+ queue.submit(alice) {
+ delay(200)
+ completed.add("slow-first")
+ }
+ queue.submit(alice) { completed.add("fast-second") }
+
+ awaitSize(completed, 2)
+ assertEquals(listOf("slow-first", "fast-second"), completed.toList())
+ }
+
+ @Test
+ fun `one player's slow work does not hold up another player`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) {
+ delay(1_000)
+ completed.add("alice")
+ }
+ queue.submit(bob) { completed.add("bob") }
+
+ awaitSize(completed, 1)
+ assertEquals(listOf("bob"), completed.toList())
+ }
+
+ @Test
+ fun `submit does not block the caller`() {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val started = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) {
+ delay(2_000)
+ started.add("done")
+ }
+
+ // submit returns without waiting for the work; the point of queueing off the tick thread.
+ assertTrue(started.isEmpty())
+ }
+
+ @Test
+ fun `work already queued still runs after the player is released`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) {
+ delay(50)
+ completed.add("in-flight")
+ }
+ queue.release(alice)
+
+ awaitSize(completed, 1)
+ assertEquals(listOf("in-flight"), completed.toList())
+ }
+
+ @Test
+ fun `a released player gets a fresh queue if they come back`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) { completed.add("before") }
+ awaitSize(completed, 1)
+ queue.release(alice)
+
+ queue.submit(alice) { completed.add("after") }
+
+ awaitSize(completed, 2)
+ assertEquals(listOf("before", "after"), completed.toList())
+ }
+
+ @Test
+ fun `a failed item does not stop the player's later work`() =
+ runBlocking {
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), TestUtils.TestLogger())
+ val completed = ConcurrentLinkedQueue<String>()
+
+ // Without a guard around each item, the throw would end the consumer loop while its
+ // channel stayed registered - so everything queued afterwards would be buffered and
+ // never delivered, and the player would have no way out short of reconnecting.
+ queue.submit(alice) { error("delivery blew up") }
+ queue.submit(alice) { completed.add("after-failure") }
+
+ awaitSize(completed, 1)
+ assertEquals(listOf("after-failure"), completed.toList())
+ }
+
+ @Test
+ fun `a failed item is reported rather than swallowed`() =
+ runBlocking {
+ val logger = TestUtils.TestLogger()
+ val queue = PerPlayerWorkQueue(CoroutineScope(Dispatchers.Default), logger)
+ val completed = ConcurrentLinkedQueue<String>()
+
+ queue.submit(alice) { error("delivery blew up") }
+ queue.submit(alice) { completed.add("done") }
+ awaitSize(completed, 1)
+
+ assertTrue(logger.severeMessages.any { it.contains("Queued work failed") })
+ }
+
+ @Test
+ fun `work submitted after shutdown is reported rather than silently dropped`() {
+ val logger = TestUtils.TestLogger()
+ val scope = CoroutineScope(Dispatchers.Default)
+ val queue = PerPlayerWorkQueue(scope, logger)
+ val completed = ConcurrentLinkedQueue<String>()
+
+ scope.cancel()
+ queue.submit(alice) { completed.add("never") }
+
+ assertTrue(completed.isEmpty())
+ assertTrue(logger.warningMessages.any { it.contains("their queue is closed") })
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
index deabadf..2d2a7c7 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/TestUtils.kt
@@ -16,6 +16,7 @@ import io.mockk.mockk
import org.bukkit.entity.Player
import org.bukkit.plugin.java.JavaPlugin
import java.util.UUID
+import java.util.logging.Level
import java.util.logging.Logger
/**
@@ -43,6 +44,20 @@ object TestUtils {
severeMessages.add(msg)
}
+ // Logger.severe/warning/info do not route through each other, so code logging with an
+ // attached throwable would otherwise be invisible to every assertion here.
+ override fun log(
+ level: Level,
+ msg: String,
+ thrown: Throwable,
+ ) {
+ when (level) {
+ Level.SEVERE -> severeMessages.add(msg)
+ Level.WARNING -> warningMessages.add(msg)
+ else -> infoMessages.add(msg)
+ }
+ }
+
fun clear() {
infoMessages.clear()
warningMessages.clear()
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManagerTest.kt
index d5ae373..04bdfd8 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelManagerTest.kt
@@ -16,6 +16,7 @@ import dev.m1sk9.lunaticChat.paper.TestUtils.createTestUUID
import dev.m1sk9.lunaticChat.paper.config.key.ChannelChatFeatureConfig
import io.mockk.every
import io.mockk.mockk
+import io.mockk.slot
import io.mockk.verify
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -624,4 +625,39 @@ class ChannelManagerTest {
verify { storage.saveToDisk(any()) }
}
+
+ @Test
+ fun `a queued write sees changes made after it was queued`() {
+ val (manager, storage, _) = createManager()
+ val queued = slot<() -> ChannelData>()
+ every { storage.queueAsyncSave(capture(queued)) } returns Unit
+
+ val ownerId = createTestUUID(1)
+ manager.createChannel(createTestChannel(id = "first-ch", name = "First", ownerId = ownerId))
+ manager.createChannel(createTestChannel(id = "second-ch", name = "Second", ownerId = createTestUUID(2)))
+
+ // The debounced write runs later; it must persist the state as of then, not as of the
+ // change that started the timer.
+ val persisted = queued.captured()
+ assertTrue(persisted.channels.containsKey("first-ch"))
+ assertTrue(persisted.channels.containsKey("second-ch"))
+ }
+
+ @Test
+ fun `a queued write never persists a channel without its members`() {
+ val (manager, storage, _) = createManager()
+ val queued = slot<() -> ChannelData>()
+ every { storage.queueAsyncSave(capture(queued)) } returns Unit
+
+ val ownerId = createTestUUID(1)
+ manager.createChannel(createTestChannel(id = "keep-ch", name = "Keep", ownerId = ownerId))
+ manager.createChannel(createTestChannel(id = "drop-ch", name = "Drop", ownerId = ownerId))
+ manager.deleteChannel("drop-ch", ownerId)
+
+ // channelsCache, membersCache and activeChannels are three separate maps. The snapshot has
+ // to be taken where they are mutated, or it can catch them mid-update and persist halves.
+ val persisted = queued.captured()
+ assertEquals(persisted.channels.keys, persisted.members.keys)
+ assertTrue(persisted.activeChannels.values.all { it in persisted.channels.keys })
+ }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt
index 28e1c82..02c4d1a 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelMembershipManagerTest.kt
@@ -472,4 +472,29 @@ class ChannelMembershipManagerTest {
assertIs<ChannelPlayerBannedException>(result.exceptionOrNull())
}
+
+ @Test
+ fun `isMember should fail for a channel that does not exist`() {
+ val (membership, _, _) = createManagers()
+
+ val result = membership.isMember(createTestUUID(1), "no-such-ch")
+
+ assertIs<ChannelNotFoundException>(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `getPlayerChannels should not report a deleted channel`() {
+ val ownerId = createTestUUID(1)
+ val playerId = createTestUUID(2)
+ val (membership, channelManager, _) = createManagers()
+ channelManager.createChannel(createTestChannel(id = "keep-ch", name = "Keep", ownerId = ownerId))
+ channelManager.createChannel(createTestChannel(id = "drop-ch", name = "Drop", ownerId = ownerId))
+ membership.joinChannel(playerId, "keep-ch")
+ channelManager.setPlayerChannel(playerId, null)
+ membership.joinChannel(playerId, "drop-ch")
+
+ channelManager.deleteChannel("drop-ch", ownerId)
+
+ assertEquals(listOf("keep-ch"), membership.getPlayerChannels(playerId).getOrThrow())
+ }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt
new file mode 100644
index 0000000..2211eef
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/channel/ChannelStorageTest.kt
@@ -0,0 +1,121 @@
+package dev.m1sk9.lunaticChat.paper.chat.channel
+
+import dev.m1sk9.lunaticChat.engine.chat.channel.Channel
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelData
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelMember
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole
+import dev.m1sk9.lunaticChat.engine.exception.ChannelStorageLoadException
+import dev.m1sk9.lunaticChat.paper.DebouncedSaver
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.UUID
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.writeText
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class ChannelStorageTest {
+ private val owner = UUID.fromString("00000001-0000-0000-0000-000000000000")
+
+ private fun sampleData() =
+ ChannelData(
+ version = 1,
+ channels = mapOf("general" to Channel(id = "general", name = "General", ownerId = owner, createdAt = 42)),
+ members =
+ mapOf(
+ "general" to
+ listOf(
+ ChannelMember(
+ channelId = "general",
+ playerId = owner,
+ role = ChannelRole.OWNER,
+ joinedAt = 42,
+ ),
+ ),
+ ),
+ activeChannels = mapOf(owner.toString() to "general"),
+ )
+
+ private fun withStorage(block: (ChannelStorage, Path) -> Unit) {
+ val directory = Files.createTempDirectory("channel-storage-test")
+ try {
+ val channelsFile = directory.resolve("channels.json")
+ block(ChannelStorage(channelsFile, mockk(relaxed = true), TestUtils.TestLogger()), channelsFile)
+ } finally {
+ directory.toFile().deleteRecursively()
+ }
+ }
+
+ @Test
+ fun `saved data is loaded back unchanged`() =
+ withStorage { storage, _ ->
+ val data = sampleData()
+
+ storage.saveToDisk(data)
+
+ assertEquals(data, storage.loadFromDisk())
+ }
+
+ @Test
+ fun `saving leaves no temporary file beside the channel file`() =
+ withStorage { storage, channelsFile ->
+ storage.saveToDisk(sampleData())
+
+ assertEquals(listOf(channelsFile), channelsFile.parent.listDirectoryEntries())
+ }
+
+ @Test
+ fun `loading a missing file yields empty data rather than failing`() =
+ withStorage { storage, _ ->
+ assertEquals(ChannelData(), storage.loadFromDisk())
+ }
+
+ @Test
+ fun `loading an unparseable file fails loudly`() =
+ withStorage { storage, channelsFile ->
+ channelsFile.writeText("{ this is not json")
+
+ assertFailsWith<ChannelStorageLoadException> { storage.loadFromDisk() }
+ }
+
+ @Test
+ fun `unknown fields in the file are ignored`() =
+ withStorage { storage, channelsFile ->
+ channelsFile.writeText("""{"version":1,"channels":{},"members":{},"activeChannels":{},"future":true}""")
+
+ assertEquals(ChannelData(), storage.loadFromDisk())
+ }
+
+ @Test
+ fun `a queued save reads the data when the write runs, not when it is queued`() {
+ val directory = Files.createTempDirectory("channel-storage-test")
+ try {
+ val channelsFile = directory.resolve("channels.json")
+ val saver = mockk<DebouncedSaver>(relaxed = true)
+ val storage = ChannelStorage(channelsFile, saver, TestUtils.TestLogger())
+ val queued = slot<() -> Unit>()
+ var supplied = false
+
+ storage.queueAsyncSave {
+ supplied = true
+ sampleData()
+ }
+
+ verify { saver.request(capture(queued)) }
+ assertTrue(!supplied, "the snapshot must not be taken while queueing")
+
+ queued.captured.invoke()
+
+ assertTrue(supplied)
+ assertEquals(sampleData(), storage.loadFromDisk())
+ } finally {
+ directory.toFile().deleteRecursively()
+ }
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
index e8764b2..a2ba325 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/DirectMessageHandlerTest.kt
@@ -11,6 +11,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
+import kotlinx.coroutines.runBlocking
import org.bukkit.Bukkit
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -23,6 +24,8 @@ import kotlin.test.assertTrue
* Validates message handling with dependency injection and conversion features.
*/
class DirectMessageHandlerTest {
+ private fun <T> sync(block: suspend () -> T): T = runBlocking { block() }
+
private fun createHandler(
configuration: dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration? = null,
settingsManager: PlayerSettingsManager? = null,
@@ -40,7 +43,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = handler.sendDirectMessage(sender, recipient, "Test message")
+ val result = sync { handler.sendDirectMessage(sender, recipient, "Test message") }
assertTrue(result)
}
@@ -62,7 +65,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer(name = "Alice")
val recipient = TestUtils.createMockPlayer(name = "Bob")
- val result = handler.sendDirectMessage(sender, recipient, "Test")
+ val result = sync { handler.sendDirectMessage(sender, recipient, "Test") }
assertTrue(result)
}
@@ -81,7 +84,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = handler.sendDirectMessage(sender, recipient, "konnichiwa")
+ val result = sync { handler.sendDirectMessage(sender, recipient, "konnichiwa") }
assertTrue(result)
}
@@ -107,7 +110,7 @@ class DirectMessageHandlerTest {
val recipient = TestUtils.createMockPlayer()
// Should not throw exception and should complete quickly (within timeout)
- val result = handler.sendDirectMessage(sender, recipient, "konnichiwa")
+ val result = sync { handler.sendDirectMessage(sender, recipient, "konnichiwa") }
assertTrue(result)
}
@@ -122,7 +125,7 @@ class DirectMessageHandlerTest {
// This validates Issue #1 refactoring - ConfigManager DI
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = handler.sendDirectMessage(sender, recipient, "Test")
+ val result = sync { handler.sendDirectMessage(sender, recipient, "Test") }
assertTrue(result)
}
@@ -143,7 +146,7 @@ class DirectMessageHandlerTest {
handler.remotePlayerRegistry = registry
val sender = TestUtils.createMockPlayer(name = "Alice")
- val relayed = handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi")
+ val relayed = sync { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
assertEquals("hi", relayed)
val target = handler.getReplyTarget(sender)
@@ -174,7 +177,7 @@ class DirectMessageHandlerTest {
handler.remotePlayerRegistry = RemotePlayerRegistry(localServerName = "lobby") // empty roster
val sender = TestUtils.createMockPlayer(name = "Alice")
- handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi")
+ sync { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
assertNull(handler.getReplyTarget(sender))
}
@@ -188,7 +191,7 @@ class DirectMessageHandlerTest {
val recipient = TestUtils.createMockPlayer(name = "Bob")
every { Bukkit.getPlayer(recipient.uniqueId) } returns recipient
- handler.sendDirectMessage(sender, recipient, "hi")
+ handler.recordMessage(sender, recipient)
val target = handler.getReplyTarget(sender)
assertIs<ReplyTarget.Local>(target)
@@ -206,7 +209,7 @@ class DirectMessageHandlerTest {
handler.remotePlayerRegistry = registry
val sender = TestUtils.createMockPlayer(name = "Alice")
- handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi")
+ sync { handler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
handler.clearPlayer(sender)
assertNull(handler.getReplyTarget(sender))
@@ -225,7 +228,7 @@ class DirectMessageHandlerTest {
val sender = TestUtils.createMockPlayer()
val recipient = TestUtils.createMockPlayer()
- val result = handler.sendDirectMessage(sender, recipient, "Test")
+ val result = sync { handler.sendDirectMessage(sender, recipient, "Test") }
assertTrue(result)
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt
index 208d4bf..d821cf8 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/TellCommandTest.kt
@@ -2,14 +2,19 @@ package dev.m1sk9.lunaticChat.paper.command.impl
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.paper.LunaticChat
+import dev.m1sk9.lunaticChat.paper.PerPlayerWorkQueue
import dev.m1sk9.lunaticChat.paper.TestUtils
import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler
import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.velocity.CrossServerDirectMessageManager
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.StandardTestDispatcher
import kotlin.test.Test
import kotlin.test.assertIs
@@ -17,6 +22,9 @@ class TellCommandTest {
private fun createCommand(
crossServerManager: CrossServerDirectMessageManager? = null,
localServerName: String = "lobby",
+ // Unconfined runs the queued delivery inline, so a test can assert on it right after
+ // execute() returns.
+ scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
): TellDeps {
val plugin = mockk<LunaticChat>(relaxed = true)
val dmHandler = mockk<DirectMessageHandler>(relaxed = true)
@@ -28,7 +36,16 @@ class TellCommandTest {
val ctx = mockk<CommandContext>(relaxed = true)
every { ctx.requirePlayer() } returns sender
- val command = TellCommand(plugin, dmHandler, languageManager, crossServerManager, null, localServerName)
+ val command =
+ TellCommand(
+ plugin,
+ dmHandler,
+ languageManager,
+ crossServerManager,
+ null,
+ localServerName,
+ PerPlayerWorkQueue(scope, TestUtils.TestLogger()),
+ )
return TellDeps(command, ctx, dmHandler, crossServerManager, sender)
}
@@ -57,7 +74,7 @@ class TellCommandTest {
val result = deps.command.execute(deps.ctx, "Bob@survival", "hello")
assertIs<CommandResult.Success>(result)
- verify { manager.sendCrossServerMessage(deps.sender, "Bob", "survival", "hello") }
+ coVerify { manager.sendCrossServerMessage(deps.sender, "Bob", "survival", "hello") }
}
@Test
@@ -69,7 +86,7 @@ class TellCommandTest {
val result = deps.command.execute(deps.ctx, "Alice@lobby", "hello")
assertIs<CommandResult.Failure>(result)
- verify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
+ coVerify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
}
@Test
@@ -80,7 +97,7 @@ class TellCommandTest {
val result = deps.command.execute(deps.ctx, "Bob@", "hello")
assertIs<CommandResult.Failure>(result)
- verify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
+ coVerify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
}
@Test
@@ -91,7 +108,7 @@ class TellCommandTest {
val result = deps.command.parseAndExecute(deps.ctx, "Bob@survival hello there")
assertIs<CommandResult.Success>(result)
- verify { manager.sendCrossServerMessage(deps.sender, "Bob", "survival", "hello there") }
+ coVerify { manager.sendCrossServerMessage(deps.sender, "Bob", "survival", "hello there") }
}
@Test
@@ -102,6 +119,37 @@ class TellCommandTest {
val result = deps.command.parseAndExecute(deps.ctx, "Bob@survival")
assertIs<CommandResult.Failure>(result)
- verify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
+ coVerify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `execute returns without waiting for delivery`() {
+ val dispatcher = StandardTestDispatcher()
+ val manager = mockk<CrossServerDirectMessageManager>(relaxed = true)
+ val deps = createCommand(crossServerManager = manager, scope = CoroutineScope(dispatcher))
+
+ val result = deps.command.execute(deps.ctx, "Bob@survival", "hello")
+
+ // Delivery can reach the Google IME API; the command must not hold the tick thread for it.
+ assertIs<CommandResult.Success>(result)
+ coVerify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
+
+ dispatcher.scheduler.advanceUntilIdle()
+ coVerify { manager.sendCrossServerMessage(deps.sender, "Bob", "survival", "hello") }
+ }
+
+ @Test
+ fun `the reply target is visible before delivery has run`() {
+ val dispatcher = StandardTestDispatcher()
+ val manager = mockk<CrossServerDirectMessageManager>(relaxed = true)
+ val deps = createCommand(crossServerManager = manager, scope = CoroutineScope(dispatcher))
+
+ deps.command.execute(deps.ctx, "Bob@survival", "hello")
+
+ // /reply reads the target on the command thread. Recording it inside the queued delivery
+ // would leave it unset for as long as the conversion takes, so /r straight after /tell
+ // would report that there is nobody to reply to.
+ verify { deps.dmHandler.recordRemoteRecipient(deps.sender, "Bob", "survival") }
+ coVerify(exactly = 0) { manager.sendCrossServerMessage(any(), any(), any(), any()) }
}
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt
new file mode 100644
index 0000000..b76c050
--- /dev/null
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/ConversionCacheTest.kt
@@ -0,0 +1,135 @@
+package dev.m1sk9.lunaticChat.paper.converter
+
+import dev.m1sk9.lunaticChat.paper.TestUtils
+import java.nio.file.Files
+import java.nio.file.Path
+import kotlin.io.path.listDirectoryEntries
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ConversionCacheTest {
+ private fun withCacheFile(block: (Path) -> Unit) {
+ val directory = Files.createTempDirectory("conversion-cache-test")
+ try {
+ block(directory.resolve("cache.json"))
+ } finally {
+ directory.toFile().deleteRecursively()
+ }
+ }
+
+ private fun createCache(
+ cacheFile: Path,
+ maxEntries: Int = 500,
+ ) = ConversionCache(cacheFile, maxEntries, TestUtils.TestLogger())
+
+ @Test
+ fun `entries survive a save and reload`() =
+ withCacheFile { cacheFile ->
+ createCache(cacheFile).apply {
+ put("konnichiwa", "こんにちは")
+ put("ohayou", "おはよう")
+ saveToDisk()
+ }
+
+ val reloaded = createCache(cacheFile).apply { loadFromDisk() }
+
+ assertEquals("こんにちは", reloaded.get("konnichiwa"))
+ assertEquals("おはよう", reloaded.get("ohayou"))
+ }
+
+ @Test
+ fun `saving leaves no temporary file beside the cache file`() =
+ withCacheFile { cacheFile ->
+ createCache(cacheFile).apply {
+ put("konnichiwa", "こんにちは")
+ saveToDisk()
+ }
+
+ assertEquals(listOf(cacheFile), cacheFile.parent.listDirectoryEntries())
+ }
+
+ @Test
+ fun `a missing cache file is created empty`() =
+ withCacheFile { cacheFile ->
+ val cache = createCache(cacheFile)
+
+ cache.loadFromDisk()
+
+ assertTrue(Files.exists(cacheFile))
+ assertNull(cache.get("konnichiwa"))
+ }
+
+ @Test
+ fun `a cache written by an incompatible version is discarded`() =
+ withCacheFile { cacheFile ->
+ cacheFile.writeText("""{"version":"0","entries":{"konnichiwa":"こんにちは"}}""")
+ val cache = createCache(cacheFile)
+
+ cache.loadFromDisk()
+
+ assertNull(cache.get("konnichiwa"))
+ }
+
+ @Test
+ fun `an unparseable cache file is discarded rather than failing the load`() =
+ withCacheFile { cacheFile ->
+ cacheFile.writeText("{ this is not json")
+ val cache = createCache(cacheFile)
+
+ cache.loadFromDisk()
+
+ assertNull(cache.get("konnichiwa"))
+ }
+
+ @Test
+ fun `an unchanged cache is not rewritten`() =
+ withCacheFile { cacheFile ->
+ val cache =
+ createCache(cacheFile).apply {
+ put("konnichiwa", "こんにちは")
+ saveToDisk()
+ }
+ // The periodic task fires on a fixed interval whether or not anyone chatted, so a clean
+ // cache must cost nothing.
+ cacheFile.writeText("sentinel")
+
+ cache.saveToDisk()
+
+ assertEquals("sentinel", cacheFile.readText())
+ }
+
+ @Test
+ fun `a change since the last save is written`() =
+ withCacheFile { cacheFile ->
+ val cache =
+ createCache(cacheFile).apply {
+ put("konnichiwa", "こんにちは")
+ saveToDisk()
+ }
+ cacheFile.writeText("sentinel")
+
+ cache.put("ohayou", "おはよう")
+ cache.saveToDisk()
+
+ assertNotEquals("sentinel", cacheFile.readText())
+ assertEquals("おはよう", createCache(cacheFile).apply { loadFromDisk() }.get("ohayou"))
+ }
+
+ @Test
+ fun `the cache stays within its entry limit`() =
+ withCacheFile { cacheFile ->
+ val cache = createCache(cacheFile, maxEntries = 10)
+
+ repeat(20) { cache.put("word$it", "変換$it") }
+ cache.saveToDisk()
+
+ val entries = createCache(cacheFile).apply { loadFromDisk() }
+ assertEquals("変換19", entries.get("word19"))
+ assertTrue((0 until 20).count { entries.get("word$it") != null } <= 10)
+ }
+}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
index 1e6d3c0..eba2c54 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/converter/RomanjiConverterTest.kt
@@ -7,7 +7,10 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
@@ -298,4 +301,40 @@ class RomanjiConverterTest {
assertEquals("おはよう", result)
verify(exactly = 1) { cache.put("ohayou", "おはよう") }
}
+
+ @Test
+ fun `words in one message are converted concurrently`() =
+ runBlocking {
+ val (converter, _, apiClient) = createConverter()
+ val inFlight = AtomicInteger(0)
+ val peakInFlight = AtomicInteger(0)
+
+ coEvery { apiClient.convert(any()) } coAnswers {
+ peakInFlight.updateAndGet { maxOf(it, inFlight.incrementAndGet()) }
+ delay(50)
+ inFlight.decrementAndGet()
+ "変換"
+ }
+
+ converter.convert("konnichiwa ohayou arigatou")
+
+ assertEquals(3, peakInFlight.get(), "each word should be in flight at the same time")
+ }
+
+ @Test
+ fun `a message-level timeout caches nothing`() =
+ runBlocking {
+ val (converter, cache, apiClient) = createConverter()
+ coEvery { apiClient.convert(any()) } coAnswers {
+ delay(1_000)
+ "変換"
+ }
+
+ val result = withTimeoutOrNull(100) { converter.convert("konnichiwa ohayou arigatou") }
+
+ assertNull(result)
+ // The words share one timeout, so caching the hiragana fallback here would pin the whole
+ // message - not just one word - to its unconverted form for the life of the cache.
+ verify(exactly = 0) { cache.put(any(), any()) }
+ }
}
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManagerTest.kt
index 158d985..6774be8 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/LanguageManagerTest.kt
@@ -63,13 +63,21 @@ class LanguageManagerTest {
}
@Test
- fun `initialize should load both language files`() {
- val (manager, logger) = createLanguageManager(Language.EN)
+ fun `initialize should load the selected language alongside the English fallback`() {
+ val (manager, logger) = createLanguageManager(Language.JA)
manager.initialize()
- // Both en.yml and ja.yml should be loaded
- assertTrue(logger.infoMessages.any { it.contains("en.yml") })
assertTrue(logger.infoMessages.any { it.contains("ja.yml") })
+ assertTrue(logger.infoMessages.any { it.contains("en.yml") })
+ }
+
+ @Test
+ fun `initialize should not load languages that cannot be read`() {
+ val (manager, logger) = createLanguageManager(Language.EN)
+ manager.initialize()
+
+ // English is both the selection and the fallback, so nothing else is worth parsing.
+ assertTrue(logger.infoMessages.none { it.contains("ja.yml") })
}
@Test
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt
index b7e1385..3c34295 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/settings/PlayerSettingsManagerTest.kt
@@ -154,6 +154,17 @@ class PlayerSettingsManagerTest {
}
@Test
+ fun `queueSave should not write on the calling thread`() {
+ val (manager, storage, _) = createManager()
+ manager.initialize()
+
+ manager.queueSave()
+
+ verify(exactly = 1) { storage.queueAsyncSave(any()) }
+ verify(exactly = 0) { storage.saveToDisk(any()) }
+ }
+
+ @Test
fun `multiple players should have independent settings`() {
val (manager, _, _) = createManager()
manager.initialize()
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt
index cf9f529..03938fc 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManagerTest.kt
@@ -7,6 +7,7 @@ import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.coroutines.runBlocking
import net.kyori.adventure.text.Component
import org.bukkit.plugin.Plugin
import java.util.UUID
@@ -14,6 +15,8 @@ import java.util.logging.Logger
import kotlin.test.Test
class CrossServerDirectMessageManagerTest {
+ private fun <T> sync(block: suspend () -> T): T = runBlocking { block() }
+
private class Fixture(
cacheSize: Int = 100,
) {
@@ -53,11 +56,11 @@ class CrossServerDirectMessageManagerTest {
fun `sendCrossServerMessage relays via plugin channel and delegates display`() {
val f = Fixture()
val sender = TestUtils.createMockPlayer(name = "Alice")
- every { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } returns "hi"
+ every { sync { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } } returns "hi"
- f.manager.sendCrossServerMessage(sender, "Bob", "survival", "hi")
+ sync { f.manager.sendCrossServerMessage(sender, "Bob", "survival", "hi") }
- verify { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") }
+ verify { sync { f.dmHandler.handleOutgoingCrossServerMessage(sender, "Bob", "survival", "hi") } }
verify { sender.sendPluginMessage(f.plugin, "lunaticchat:main", any<ByteArray>()) }
}
@@ -140,9 +143,9 @@ class CrossServerDirectMessageManagerTest {
fun `sendCrossServerMessage prunes the dedup cache when over capacity`() {
val f = Fixture(cacheSize = 1)
val sender = TestUtils.createMockPlayer(name = "Alice")
- every { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any()) } returns "hi"
+ every { sync { f.dmHandler.handleOutgoingCrossServerMessage(any(), any(), any(), any()) } } returns "hi"
- repeat(3) { f.manager.sendCrossServerMessage(sender, "Bob$it", "survival", "hi") }
+ repeat(3) { sync { f.manager.sendCrossServerMessage(sender, "Bob$it", "survival", "hi") } }
verify(atLeast = 1) { sender.sendPluginMessage(f.plugin, "lunaticchat:main", any<ByteArray>()) }
}
diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt
index f47db70..bebc7b9 100644
--- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt
+++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt
@@ -43,9 +43,12 @@ class CrossServerChatRelay(
relayCount++
}
- logger.info(
- "Relayed global chat message from ${message.serverName} to $relayCount servers " +
- "(messageId=${message.messageId}, player=${message.playerName})",
+ logger.debug(
+ "Relayed global chat message from {} to {} servers (messageId={}, player={})",
+ message.serverName,
+ relayCount,
+ message.messageId,
+ message.playerName,
)
} catch (e: Exception) {
logger.error("Failed to relay global chat message: ${e.message}", e)
diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt
index 3746395..f9cc52d 100644
--- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt
+++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt
@@ -62,9 +62,13 @@ class CrossServerDirectMessageRelay(
}
targetServer.sendPluginMessage(CHANNEL, PluginMessageCodec.encode(message))
- logger.info(
- "Relayed direct message from ${message.senderName}@${message.sourceServerName} " +
- "to ${message.targetName}@${message.targetServerName} (messageId=${message.messageId})",
+ logger.debug(
+ "Relayed direct message from {}@{} to {}@{} (messageId={})",
+ message.senderName,
+ message.sourceServerName,
+ message.targetName,
+ message.targetServerName,
+ message.messageId,
)
} catch (e: Exception) {
logger.error("Failed to relay direct message: ${e.message}", e)
diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt
index 6b40a7e..aa0cc07 100644
--- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt
+++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt
@@ -170,9 +170,11 @@ class PluginMessageHandler(
connection: ServerConnection,
message: PluginMessage.GlobalChatMessage,
) {
- logger.info(
- "Received global chat message from ${connection.serverInfo.name}: " +
- "messageId=${message.messageId}, player=${message.playerName}",
+ logger.debug(
+ "Received global chat message from {}: messageId={}, player={}",
+ connection.serverInfo.name,
+ message.messageId,
+ message.playerName,
)
crossServerChatRelay.relayGlobalMessage(message, connection.server)
diff --git a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelayTest.kt b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelayTest.kt
index 50b0db2..1cd5fbf 100644
--- a/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelayTest.kt
+++ b/platform-velocity/src/test/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelayTest.kt
@@ -4,11 +4,14 @@ import com.velocitypowered.api.proxy.ProxyServer
import com.velocitypowered.api.proxy.messages.ChannelIdentifier
import com.velocitypowered.api.proxy.server.RegisteredServer
import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage
+import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec
import io.mockk.every
import io.mockk.mockk
+import io.mockk.slot
import io.mockk.verify
import org.slf4j.Logger
import kotlin.test.Test
+import kotlin.test.assertEquals
class CrossServerChatRelayTest {
private fun createRelay(): Triple<CrossServerChatRelay, ProxyServer, Logger> {
@@ -70,7 +73,7 @@ class CrossServerChatRelayTest {
@Test
fun `relayGlobalMessage with only source server should relay to zero`() {
- val (relay, proxyServer, logger) = createRelay()
+ val (relay, proxyServer, _) = createRelay()
val sourceServer = createRegisteredServer("lobby")
every { proxyServer.allServers } returns listOf(sourceServer)
@@ -78,27 +81,22 @@ class CrossServerChatRelayTest {
relay.relayGlobalMessage(createTestMessage(), sourceServer)
verify(exactly = 0) { sourceServer.sendPluginMessage(any<ChannelIdentifier>(), any<ByteArray>()) }
- verify { logger.info(match { it.contains("0 servers") }) }
}
@Test
- fun `relayGlobalMessage should log with messageId and playerName`() {
- val (relay, proxyServer, logger) = createRelay()
+ fun `relayGlobalMessage should forward the message unchanged`() {
+ val (relay, proxyServer, _) = createRelay()
val sourceServer = createRegisteredServer("lobby")
val targetServer = createRegisteredServer("survival")
+ val relayed = slot<ByteArray>()
every { proxyServer.allServers } returns listOf(sourceServer, targetServer)
+ every { targetServer.sendPluginMessage(any<ChannelIdentifier>(), capture(relayed)) } returns true
val message = createTestMessage(messageId = "test-msg-123")
relay.relayGlobalMessage(message, sourceServer)
- verify {
- logger.info(
- match { msg ->
- msg.contains("test-msg-123") && msg.contains("TestPlayer")
- },
- )
- }
+ assertEquals(message, PluginMessageCodec.decode(relayed.captured))
}
@Test