diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-05 01:04:51 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-05 01:04:51 +0900 |
| commit | 27457cdb5f95c9fcb70a455034410097e30a30d6 (patch) | |
| tree | 146936a86d8488ac5b078238d24f1f56e8cd38be | |
| parent | 9a57b249dd660c49ac968ab2d3246736772504e1 (diff) | |
| parent | 6e8b634ff0f7197e259cd899e97a1d363e0b62e6 (diff) | |
| download | LunaticChat-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
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 |
