diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-03 15:12:42 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-08-05 01:15:29 +0900 |
| commit | daf9060561656f468a0ae366e5695f5eb74f48f7 (patch) | |
| tree | 4e096081884cc5039edb198b77d2bd0f70e5751e | |
| parent | fd56ebaa1dc2cfa8f834edb06395e8fc02375d1d (diff) | |
| download | LunaticChat-daf9060561656f468a0ae366e5695f5eb74f48f7.tar.gz LunaticChat-daf9060561656f468a0ae366e5695f5eb74f48f7.tar.bz2 LunaticChat-daf9060561656f468a0ae366e5695f5eb74f48f7.zip | |
refactor: make the direct message failure reason an enum
DirectMessageError.reason was a String backed by two constants, so the
receiving side matched one case and let everything else fall through to
"the target is offline". Adding a third reason on the proxy would have
shipped it to Paper servers that silently reported the wrong thing - the
one string-keyed dispatch sitting next to a protocol layer whose messages
are otherwise a sealed hierarchy with exhaustiveness checking.
As an enum, the reader must decide what to show for each case, and
CrossServerDirectMessageManager's when no longer needs an else.
The wire format is unchanged: kotlinx serializes an enum as its name, so
the existing snapshots still decode. What did need care is the reverse
direction - a reason from a newer proxy would now fail to parse, where the
String version degraded. The property has a default and the codec enables
coerceInputValues, so an unknown reason lands on TARGET_OFFLINE, exactly
the old else branch. There is a compatibility test for that case.
Co-Authored-By: Claude <noreply@anthropic.com>
6 files changed, 39 insertions, 10 deletions
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt index 1ba5c25..689a57c 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt @@ -113,7 +113,9 @@ sealed interface PluginMessage { * @property senderId Sender UUID as string (used to locate the sender to notify) * @property targetName Target player name that was requested * @property targetServerName Target server name that was requested - * @property reason Failure reason: [Reason.TARGET_OFFLINE] or [Reason.SERVER_NOT_FOUND] + * @property reason Why delivery failed. Defaults to [Reason.TARGET_OFFLINE] so that a reason + * added by a newer peer degrades to the generic failure rather than failing to decode - + * [PluginMessageCodec] enables coerceInputValues for exactly this. */ @Serializable data class DirectMessageError( @@ -121,11 +123,18 @@ sealed interface PluginMessage { val senderId: String, val targetName: String, val targetServerName: String, - val reason: String, + val reason: Reason = Reason.TARGET_OFFLINE, ) : PluginMessage { - object Reason { - const val TARGET_OFFLINE = "TARGET_OFFLINE" - const val SERVER_NOT_FOUND = "SERVER_NOT_FOUND" + /** + * Why a cross-server direct message could not be delivered. + * + * An enum rather than string constants so that adding a case forces every reader to + * decide what to show for it, instead of silently reporting the existing default. + */ + @Serializable + enum class Reason { + TARGET_OFFLINE, + SERVER_NOT_FOUND, } } diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt index 14d1f82..f3b52c6 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt @@ -13,7 +13,13 @@ import java.io.DataOutputStream * Format: [subChannel: UTF][messageJson: UTF] */ object PluginMessageCodec { - private val json = Json { ignoreUnknownKeys = true } + private val json = + Json { + ignoreUnknownKeys = true + // An enum value this build does not know falls back to the property's default rather + // than failing the whole message, matching how unknown fields are treated. + coerceInputValues = true + } /** * Sub-channel names diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt index 0eb56c1..e1c65ba 100644 --- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt @@ -291,7 +291,7 @@ class PluginMessageCodecTest { PluginMessage.StatusResponse("1.0.0", "1.0.0", true), PluginMessage.GlobalChatMessage("id", "srv", "pid", "name", "msg", 0L), PluginMessage.DirectMessageRelay("id", "src", "sid", "sname", "tsrv", "tname", "msg", 0L), - PluginMessage.DirectMessageError("id", "sid", "tname", "tsrv", "TARGET_OFFLINE"), + PluginMessage.DirectMessageError("id", "sid", "tname", "tsrv", PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE), PluginMessage.PresenceSnapshot(listOf(PresenceEntry("p", "s")), 0L), PluginMessage.PresenceRequest, ) diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt index c9bc6c0..e70edda 100644 --- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt @@ -174,7 +174,7 @@ class ProtocolBackwardCompatibilityTest { assertEquals("00000005-0000-0000-0000-000000000000", decoded.senderId) assertEquals("Ghost", decoded.targetName) assertEquals("lobby", decoded.targetServerName) - assertEquals("TARGET_OFFLINE", decoded.reason) + assertEquals(PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE, decoded.reason) } @Test @@ -196,4 +196,18 @@ class ProtocolBackwardCompatibilityTest { assertIs<PluginMessage.PresenceRequest>(decoded) } + + @Test + fun `a failure reason this build does not know decodes to the generic failure`() { + val fromNewerPeer = + """{"messageId":"dm-789","senderId":"00000006-0000-0000-0000-000000000000",""" + + """"targetName":"Ghost","targetServerName":"lobby","reason":"RATE_LIMITED"}""" + + val decoded = PluginMessageCodec.decode(buildRawMessage("direct_message_error", fromNewerPeer)) + + // A proxy that learns a new reason must not make this build drop the whole message. + assertIs<PluginMessage.DirectMessageError>(decoded) + assertEquals("dm-789", decoded.messageId) + assertEquals(PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE, decoded.reason) + } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt index ccd8d40..8328ad8 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerDirectMessageManager.kt @@ -132,7 +132,7 @@ class CrossServerDirectMessageManager( val messageKey = when (error.reason) { PluginMessage.DirectMessageError.Reason.SERVER_NOT_FOUND -> "directMessage.remoteServerNotFound" - else -> "directMessage.remoteTargetOffline" + PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE -> "directMessage.remoteTargetOffline" } val text = languageManager.getMessage( diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt index f9cc52d..70ca525 100644 --- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerDirectMessageRelay.kt @@ -78,7 +78,7 @@ class CrossServerDirectMessageRelay( private fun sendError( sourceServer: RegisteredServer, message: PluginMessage.DirectMessageRelay, - reason: String, + reason: PluginMessage.DirectMessageError.Reason, ) { val error = PluginMessage.DirectMessageError( |
