summaryrefslogtreecommitdiff
path: root/engine
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-06-17 16:15:40 +0900
committerSho Sakuma <me@m1sk9.dev>2026-06-17 16:15:40 +0900
commitc678bd15324dad0519cb1d9a4c58202a981ee8e2 (patch)
tree12e31ff3ba8b34fc95b1fe5b3279e02079b8c99b /engine
parent7e06f2d77e121827e7eabdcb508deb04417b0860 (diff)
downloadLunaticChat-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')
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt85
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt28
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt2
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt103
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt59
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)
+ }
}