diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-06-17 16:15:40 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-06-17 16:15:40 +0900 |
| commit | c678bd15324dad0519cb1d9a4c58202a981ee8e2 (patch) | |
| tree | 12e31ff3ba8b34fc95b1fe5b3279e02079b8c99b /engine | |
| parent | 7e06f2d77e121827e7eabdcb508deb04417b0860 (diff) | |
| download | LunaticChat-c678bd15324dad0519cb1d9a4c58202a981ee8e2.tar.gz LunaticChat-c678bd15324dad0519cb1d9a4c58202a981ee8e2.tar.bz2 LunaticChat-c678bd15324dad0519cb1d9a4c58202a981ee8e2.zip | |
feat: add cross-server direct messaging via Velocity
Allow /tell and /reply to reach players on other Paper servers behind a
Velocity proxy using the "<player>@<server>" target syntax.
Engine (protocol bumped 1.0.0 -> 1.0.1, optional sub-channels):
- Add DirectMessageRelay, DirectMessageError, PresenceSnapshot/PresenceEntry
and PresenceRequest messages plus codec branches.
Velocity:
- CrossServerDirectMessageRelay routes a DM to the target server (or returns
a delivery error to the source).
- PresenceTracker broadcasts proxy-wide presence snapshots on join/quit/switch
and on request.
Paper:
- RemotePlayerRegistry caches proxy presence for completion and remote target
resolution.
- CrossServerDirectMessageManager handles send/receive/error and dedup.
- DirectMessageHandler reply state generalized to ReplyTarget (Local/Remote)
so /reply works across servers.
- TellCommand parses "name@server", completes local names and remote
name@server targets, and uses exact local name matching.
- New crossServerDirectMessage config flag and i18n keys (en/ja).
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'engine')
5 files changed, 276 insertions, 1 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 f7090da..1ba5c25 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 @@ -78,4 +78,89 @@ sealed interface PluginMessage { val message: String, val timestamp: Long = System.currentTimeMillis(), ) : PluginMessage + + /** + * Cross-server direct message relay (Paper → Velocity → target Paper) + * + * Routed to a single target server instead of broadcast. Romaji conversion is + * applied on the sending side, so [message] carries the already-converted text. + * + * @property messageId UUID for deduplication + * @property sourceServerName Sender's server identifier (used for reply routing) + * @property senderId Sender UUID as string + * @property senderName Sender name + * @property targetServerName Target server identifier (routing key) + * @property targetName Target player name + * @property message Message content (romaji-converted if applicable) + * @property timestamp Message timestamp (milliseconds since epoch) + */ + @Serializable + data class DirectMessageRelay( + val messageId: String, + val sourceServerName: String, + val senderId: String, + val senderName: String, + val targetServerName: String, + val targetName: String, + val message: String, + val timestamp: Long = System.currentTimeMillis(), + ) : PluginMessage + + /** + * Cross-server direct message delivery failure (Velocity → source Paper) + * + * @property messageId UUID of the failed relay + * @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] + */ + @Serializable + data class DirectMessageError( + val messageId: String, + val senderId: String, + val targetName: String, + val targetServerName: String, + val reason: String, + ) : PluginMessage { + object Reason { + const val TARGET_OFFLINE = "TARGET_OFFLINE" + const val SERVER_NOT_FOUND = "SERVER_NOT_FOUND" + } + } + + /** + * Proxy-wide player presence snapshot (Velocity → Paper) + * + * Sent on join/quit/server-switch and in response to [PresenceRequest]. + * Replaces the receiver's cached roster entirely. + * + * @property players All players currently connected to the proxy + * @property timestamp Snapshot timestamp (milliseconds since epoch) + */ + @Serializable + data class PresenceSnapshot( + val players: List<PresenceEntry>, + val timestamp: Long = System.currentTimeMillis(), + ) : PluginMessage + + /** + * Presence snapshot request (Paper → Velocity) + * + * Sent after a successful handshake to obtain the initial roster. + */ + @Serializable + data object PresenceRequest : PluginMessage } + +/** + * A single player presence entry used in [PluginMessage.PresenceSnapshot]. + * + * @property playerName Player name + * @property serverName Name of the server the player is currently connected to + */ +@Serializable +data class PresenceEntry( + val playerName: String, + val serverName: String, +) 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 85b51f0..14d1f82 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 @@ -24,6 +24,10 @@ object PluginMessageCodec { const val STATUS_REQUEST = "status_request" const val STATUS_RESPONSE = "status_response" const val GLOBAL_CHAT = "global_chat" + const val DIRECT_MESSAGE = "direct_message" + const val DIRECT_MESSAGE_ERROR = "direct_message_error" + const val PRESENCE_SNAPSHOT = "presence_snapshot" + const val PRESENCE_REQUEST = "presence_request" } /** @@ -53,6 +57,18 @@ object PluginMessageCodec { is PluginMessage.GlobalChatMessage -> { SubChannel.GLOBAL_CHAT to json.encodeToString(message) } + is PluginMessage.DirectMessageRelay -> { + SubChannel.DIRECT_MESSAGE to json.encodeToString(message) + } + is PluginMessage.DirectMessageError -> { + SubChannel.DIRECT_MESSAGE_ERROR to json.encodeToString(message) + } + is PluginMessage.PresenceSnapshot -> { + SubChannel.PRESENCE_SNAPSHOT to json.encodeToString(message) + } + is PluginMessage.PresenceRequest -> { + SubChannel.PRESENCE_REQUEST to "{}" + } } dataOut.writeUTF(subChannel) @@ -91,6 +107,18 @@ object PluginMessageCodec { SubChannel.GLOBAL_CHAT -> { json.decodeFromString<PluginMessage.GlobalChatMessage>(messageJson) } + SubChannel.DIRECT_MESSAGE -> { + json.decodeFromString<PluginMessage.DirectMessageRelay>(messageJson) + } + SubChannel.DIRECT_MESSAGE_ERROR -> { + json.decodeFromString<PluginMessage.DirectMessageError>(messageJson) + } + SubChannel.PRESENCE_SNAPSHOT -> { + json.decodeFromString<PluginMessage.PresenceSnapshot>(messageJson) + } + SubChannel.PRESENCE_REQUEST -> { + PluginMessage.PresenceRequest + } else -> throw IllegalArgumentException("Unknown sub-channel: $subChannel") } } diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt index 3cbb2cd..6585c86 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt @@ -33,7 +33,7 @@ package dev.m1sk9.lunaticChat.engine.protocol object ProtocolVersion { const val MAJOR = 1 const val MINOR = 0 - const val PATCH = 0 + const val PATCH = 1 /** * Minimum MINOR version this build can interoperate with (same MAJOR). 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 e848780..0eb56c1 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 @@ -136,6 +136,101 @@ class PluginMessageCodecTest { } @Test + fun `encode and decode DirectMessageRelay round-trip`() { + val original = + PluginMessage.DirectMessageRelay( + messageId = "dm-id-123", + sourceServerName = "survival", + senderId = "00000004-0000-0000-0000-000000000000", + senderName = "Sender", + targetServerName = "lobby", + targetName = "Recipient", + message = "Hello across servers!", + timestamp = 4000L, + ) + + val encoded = PluginMessageCodec.encode(original) + val decoded = PluginMessageCodec.decode(encoded) + + assertIs<PluginMessage.DirectMessageRelay>(decoded) + assertEquals(original.messageId, decoded.messageId) + assertEquals(original.sourceServerName, decoded.sourceServerName) + assertEquals(original.senderId, decoded.senderId) + assertEquals(original.senderName, decoded.senderName) + assertEquals(original.targetServerName, decoded.targetServerName) + assertEquals(original.targetName, decoded.targetName) + assertEquals(original.message, decoded.message) + assertEquals(original.timestamp, decoded.timestamp) + } + + @Test + fun `encode and decode DirectMessageError round-trip`() { + val original = + PluginMessage.DirectMessageError( + messageId = "dm-id-456", + senderId = "00000005-0000-0000-0000-000000000000", + targetName = "Ghost", + targetServerName = "lobby", + reason = PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE, + ) + + val encoded = PluginMessageCodec.encode(original) + val decoded = PluginMessageCodec.decode(encoded) + + assertIs<PluginMessage.DirectMessageError>(decoded) + assertEquals(original.messageId, decoded.messageId) + assertEquals(original.senderId, decoded.senderId) + assertEquals(original.targetName, decoded.targetName) + assertEquals(original.targetServerName, decoded.targetServerName) + assertEquals(PluginMessage.DirectMessageError.Reason.TARGET_OFFLINE, decoded.reason) + } + + @Test + fun `encode and decode PresenceSnapshot round-trip`() { + val original = + PluginMessage.PresenceSnapshot( + players = + listOf( + PresenceEntry("Alice", "lobby"), + PresenceEntry("Bob", "survival"), + ), + timestamp = 5000L, + ) + + val encoded = PluginMessageCodec.encode(original) + val decoded = PluginMessageCodec.decode(encoded) + + assertIs<PluginMessage.PresenceSnapshot>(decoded) + assertEquals(2, decoded.players.size) + assertEquals("Alice", decoded.players[0].playerName) + assertEquals("lobby", decoded.players[0].serverName) + assertEquals("Bob", decoded.players[1].playerName) + assertEquals("survival", decoded.players[1].serverName) + assertEquals(original.timestamp, decoded.timestamp) + } + + @Test + fun `encode and decode PresenceSnapshot with empty list round-trip`() { + val original = PluginMessage.PresenceSnapshot(players = emptyList(), timestamp = 6000L) + + val encoded = PluginMessageCodec.encode(original) + val decoded = PluginMessageCodec.decode(encoded) + + assertIs<PluginMessage.PresenceSnapshot>(decoded) + assertEquals(0, decoded.players.size) + } + + @Test + fun `encode and decode PresenceRequest round-trip`() { + val original = PluginMessage.PresenceRequest + + val encoded = PluginMessageCodec.encode(original) + val decoded = PluginMessageCodec.decode(encoded) + + assertIs<PluginMessage.PresenceRequest>(decoded) + } + + @Test fun `decode should throw on unknown sub-channel`() { val out = java.io.ByteArrayOutputStream() val dataOut = java.io.DataOutputStream(out) @@ -180,6 +275,10 @@ class PluginMessageCodecTest { assertEquals("status_request", PluginMessageCodec.SubChannel.STATUS_REQUEST) assertEquals("status_response", PluginMessageCodec.SubChannel.STATUS_RESPONSE) assertEquals("global_chat", PluginMessageCodec.SubChannel.GLOBAL_CHAT) + assertEquals("direct_message", PluginMessageCodec.SubChannel.DIRECT_MESSAGE) + assertEquals("direct_message_error", PluginMessageCodec.SubChannel.DIRECT_MESSAGE_ERROR) + assertEquals("presence_snapshot", PluginMessageCodec.SubChannel.PRESENCE_SNAPSHOT) + assertEquals("presence_request", PluginMessageCodec.SubChannel.PRESENCE_REQUEST) } @Test @@ -191,6 +290,10 @@ class PluginMessageCodecTest { PluginMessage.StatusRequest, 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.PresenceSnapshot(listOf(PresenceEntry("p", "s")), 0L), + PluginMessage.PresenceRequest, ) messages.forEach { message -> 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 217baef..c9bc6c0 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 @@ -32,6 +32,16 @@ class ProtocolBackwardCompatibilityTest { const val GLOBAL_CHAT_V1_0_0 = """{"messageId":"abc-123","serverName":"lobby","playerId":"00000001-0000-0000-0000-000000000000","playerName":"TestPlayer","message":"Hello, world!","timestamp":1000}""" + + // Protocol 1.0.1 snapshots — NEVER MODIFY after commit + const val DIRECT_MESSAGE_V1_0_1 = + """{"messageId":"dm-123","sourceServerName":"survival","senderId":"00000004-0000-0000-0000-000000000000","senderName":"Sender","targetServerName":"lobby","targetName":"Recipient","message":"Hello!","timestamp":4000}""" + + const val DIRECT_MESSAGE_ERROR_V1_0_1 = + """{"messageId":"dm-456","senderId":"00000005-0000-0000-0000-000000000000","targetName":"Ghost","targetServerName":"lobby","reason":"TARGET_OFFLINE"}""" + + const val PRESENCE_SNAPSHOT_V1_0_1 = + """{"players":[{"playerName":"Alice","serverName":"lobby"},{"playerName":"Bob","serverName":"survival"}],"timestamp":5000}""" } private fun buildRawMessage( @@ -137,4 +147,53 @@ class ProtocolBackwardCompatibilityTest { assertIs<PluginMessage.StatusRequest>(decoded) } + + @Test + fun `current codec can decode protocol 1_0_1 DirectMessageRelay`() { + val data = buildRawMessage("direct_message", DIRECT_MESSAGE_V1_0_1) + val decoded = PluginMessageCodec.decode(data) + + assertIs<PluginMessage.DirectMessageRelay>(decoded) + assertEquals("dm-123", decoded.messageId) + assertEquals("survival", decoded.sourceServerName) + assertEquals("00000004-0000-0000-0000-000000000000", decoded.senderId) + assertEquals("Sender", decoded.senderName) + assertEquals("lobby", decoded.targetServerName) + assertEquals("Recipient", decoded.targetName) + assertEquals("Hello!", decoded.message) + assertEquals(4000L, decoded.timestamp) + } + + @Test + fun `current codec can decode protocol 1_0_1 DirectMessageError`() { + val data = buildRawMessage("direct_message_error", DIRECT_MESSAGE_ERROR_V1_0_1) + val decoded = PluginMessageCodec.decode(data) + + assertIs<PluginMessage.DirectMessageError>(decoded) + assertEquals("dm-456", decoded.messageId) + assertEquals("00000005-0000-0000-0000-000000000000", decoded.senderId) + assertEquals("Ghost", decoded.targetName) + assertEquals("lobby", decoded.targetServerName) + assertEquals("TARGET_OFFLINE", decoded.reason) + } + + @Test + fun `current codec can decode protocol 1_0_1 PresenceSnapshot`() { + val data = buildRawMessage("presence_snapshot", PRESENCE_SNAPSHOT_V1_0_1) + val decoded = PluginMessageCodec.decode(data) + + assertIs<PluginMessage.PresenceSnapshot>(decoded) + assertEquals(2, decoded.players.size) + assertEquals("Alice", decoded.players[0].playerName) + assertEquals("lobby", decoded.players[0].serverName) + assertEquals(5000L, decoded.timestamp) + } + + @Test + fun `current codec can decode protocol 1_0_1 PresenceRequest with empty JSON`() { + val data = buildRawMessage("presence_request", "{}") + val decoded = PluginMessageCodec.decode(data) + + assertIs<PluginMessage.PresenceRequest>(decoded) + } } |
