summaryrefslogtreecommitdiff
path: root/engine
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-04-04 20:26:37 +0900
committerSho Sakuma <me@m1sk9.dev>2026-04-04 20:26:37 +0900
commit2b0829856c9a24b90a9248d28c7de84101b83232 (patch)
tree63160192d490543ae7c1fc4a16286397da8261ee /engine
parent9027d9f04fb833f428d6ef528cb19542cef8bd49 (diff)
downloadLunaticChat-2b0829856c9a24b90a9248d28c7de84101b83232.tar.gz
LunaticChat-2b0829856c9a24b90a9248d28c7de84101b83232.tar.bz2
LunaticChat-2b0829856c9a24b90a9248d28c7de84101b83232.zip
feat: Improving Velocity's Cycling Compatibility
Diffstat (limited to 'engine')
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt3
-rw-r--r--engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersion.kt43
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodecTest.kt22
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt140
-rw-r--r--engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersionTest.kt20
5 files changed, 225 insertions, 3 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 cee7837..f7090da 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
@@ -34,6 +34,9 @@ sealed interface PluginMessage {
val compatible: Boolean,
val velocityVersion: String,
val error: String? = null,
+ val protocolMajor: Int = ProtocolVersion.MAJOR,
+ val protocolMinor: Int = ProtocolVersion.MINOR,
+ val protocolPatch: Int = ProtocolVersion.PATCH,
) : PluginMessage
/**
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 a57d3a7..3cbb2cd 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
@@ -4,26 +4,63 @@ package dev.m1sk9.lunaticChat.engine.protocol
* LunaticChat protocol version definition
*
* Manages version of communication protocol between Paper and Velocity.
- * MAJOR.MINOR must match; differences in PATCH are compatible.
+ *
+ * ## Version Bump Rules
+ *
+ * **PATCH** (e.g., 1.0.0 -> 1.0.1):
+ * - Add optional fields with default values to existing message types.
+ * - Add new sub-channels that peers can safely ignore.
+ * - No deployment coordination required.
+ *
+ * **MINOR** (e.g., 1.0.x -> 1.1.0):
+ * - Add required fields to existing messages.
+ * - Add sub-channels whose absence degrades functionality.
+ * - Deployment order: update Velocity first, then Paper servers.
+ * - Set [MIN_SUPPORTED_MINOR] to control the deprecation window.
+ *
+ * **MAJOR** (e.g., 1.x.x -> 2.0.0):
+ * - Remove or rename existing sub-channels or fields.
+ * - Change wire format or encoding.
+ * - Requires simultaneous deployment of all components.
+ *
+ * ## Adding a New Message Type (sub-channel)
+ * 1. Add the data class to [PluginMessage].
+ * 2. Add a sub-channel constant to [PluginMessageCodec.SubChannel].
+ * 3. Add encode/decode branches in [PluginMessageCodec].
+ * 4. Add a backward compatibility snapshot to ProtocolBackwardCompatibilityTest.
+ * 5. Bump PATCH if the new sub-channel is optional, MINOR if it is required.
*/
object ProtocolVersion {
const val MAJOR = 1
const val MINOR = 0
const val PATCH = 0
+ /**
+ * Minimum MINOR version this build can interoperate with (same MAJOR).
+ *
+ * When bumping MINOR, set this to the oldest MINOR version that should still
+ * be accepted. This allows a controlled deprecation window for rolling updates.
+ */
+ const val MIN_SUPPORTED_MINOR = 0
+
val version: String = "$MAJOR.$MINOR.$PATCH"
/**
* Checks if specified protocol version is compatible
*
+ * Compatibility rules:
+ * - MAJOR must match exactly.
+ * - Remote MINOR must be >= [MIN_SUPPORTED_MINOR] and <= [MINOR].
+ * - PATCH is always ignored.
+ *
* @param major Major version
* @param minor Minor version
- * @return true if MAJOR and MINOR match
+ * @return true if compatible
*/
fun isCompatible(
major: Int,
minor: Int,
- ): Boolean = MAJOR == major && MINOR == minor
+ ): Boolean = MAJOR == major && minor in MIN_SUPPORTED_MINOR..MINOR
/**
* Checks compatibility from version string
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 c792aad..e848780 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
@@ -45,6 +45,28 @@ class PluginMessageCodecTest {
}
@Test
+ fun `encode and decode HandshakeResponse with protocol version fields`() {
+ val original =
+ PluginMessage.HandshakeResponse(
+ compatible = true,
+ velocityVersion = "0.11.0",
+ protocolMajor = 1,
+ protocolMinor = 1,
+ protocolPatch = 0,
+ )
+
+ val encoded = PluginMessageCodec.encode(original)
+ val decoded = PluginMessageCodec.decode(encoded)
+
+ assertIs<PluginMessage.HandshakeResponse>(decoded)
+ assertEquals(original.compatible, decoded.compatible)
+ assertEquals(original.velocityVersion, decoded.velocityVersion)
+ assertEquals(1, decoded.protocolMajor)
+ assertEquals(1, decoded.protocolMinor)
+ assertEquals(0, decoded.protocolPatch)
+ }
+
+ @Test
fun `encode and decode HandshakeResponse incompatible round-trip`() {
val original =
PluginMessage.HandshakeResponse(
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
new file mode 100644
index 0000000..217baef
--- /dev/null
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolBackwardCompatibilityTest.kt
@@ -0,0 +1,140 @@
+package dev.m1sk9.lunaticChat.engine.protocol
+
+import java.io.ByteArrayOutputStream
+import java.io.DataOutputStream
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Backward compatibility tests using fixed JSON snapshots.
+ *
+ * These snapshots represent the wire format of protocol version 1.0.0.
+ * DO NOT MODIFY these constants after commit — they are the compatibility contract.
+ * If a test fails after modifying a @Serializable class, it means backward compatibility is broken.
+ */
+class ProtocolBackwardCompatibilityTest {
+ companion object {
+ // Protocol 1.0.0 snapshots — NEVER MODIFY after commit
+ const val HANDSHAKE_V1_0_0 =
+ """{"pluginVersion":"0.10.0","protocolMajor":1,"protocolMinor":0,"protocolPatch":0}"""
+
+ const val HANDSHAKE_RESPONSE_V1_0_0 =
+ """{"compatible":true,"velocityVersion":"0.10.0"}"""
+
+ const val HANDSHAKE_RESPONSE_INCOMPATIBLE_V1_0_0 =
+ """{"compatible":false,"velocityVersion":"0.10.0","error":"Version mismatch"}"""
+
+ const val STATUS_RESPONSE_V1_0_0 =
+ """{"velocityVersion":"0.10.0","protocolVersion":"1.0.0","online":true}"""
+
+ 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}"""
+ }
+
+ private fun buildRawMessage(
+ subChannel: String,
+ json: String,
+ ): ByteArray {
+ val out = ByteArrayOutputStream()
+ val dataOut = DataOutputStream(out)
+ dataOut.writeUTF(subChannel)
+ dataOut.writeUTF(json)
+ return out.toByteArray()
+ }
+
+ @Test
+ fun `current codec can decode protocol 1_0_0 Handshake`() {
+ val data = buildRawMessage("handshake", HANDSHAKE_V1_0_0)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.Handshake>(decoded)
+ assertEquals("0.10.0", decoded.pluginVersion)
+ assertEquals(1, decoded.protocolMajor)
+ assertEquals(0, decoded.protocolMinor)
+ assertEquals(0, decoded.protocolPatch)
+ }
+
+ @Test
+ fun `current codec can decode HandshakeResponse without protocol version fields`() {
+ val data = buildRawMessage("handshake_response", HANDSHAKE_RESPONSE_V1_0_0)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.HandshakeResponse>(decoded)
+ assertTrue(decoded.compatible)
+ assertEquals("0.10.0", decoded.velocityVersion)
+ assertNull(decoded.error)
+ // Protocol fields should get defaults when missing from old format
+ assertEquals(ProtocolVersion.MAJOR, decoded.protocolMajor)
+ assertEquals(ProtocolVersion.MINOR, decoded.protocolMinor)
+ assertEquals(ProtocolVersion.PATCH, decoded.protocolPatch)
+ }
+
+ @Test
+ fun `current codec can decode HandshakeResponse incompatible without protocol version fields`() {
+ val data = buildRawMessage("handshake_response", HANDSHAKE_RESPONSE_INCOMPATIBLE_V1_0_0)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.HandshakeResponse>(decoded)
+ assertEquals(false, decoded.compatible)
+ assertEquals("Version mismatch", decoded.error)
+ }
+
+ @Test
+ fun `current codec can decode protocol 1_0_0 StatusResponse`() {
+ val data = buildRawMessage("status_response", STATUS_RESPONSE_V1_0_0)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.StatusResponse>(decoded)
+ assertEquals("0.10.0", decoded.velocityVersion)
+ assertEquals("1.0.0", decoded.protocolVersion)
+ assertTrue(decoded.online)
+ }
+
+ @Test
+ fun `current codec can decode protocol 1_0_0 GlobalChatMessage`() {
+ val data = buildRawMessage("global_chat", GLOBAL_CHAT_V1_0_0)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.GlobalChatMessage>(decoded)
+ assertEquals("abc-123", decoded.messageId)
+ assertEquals("lobby", decoded.serverName)
+ assertEquals("00000001-0000-0000-0000-000000000000", decoded.playerId)
+ assertEquals("TestPlayer", decoded.playerName)
+ assertEquals("Hello, world!", decoded.message)
+ assertEquals(1000L, decoded.timestamp)
+ }
+
+ @Test
+ fun `current codec ignores unknown fields in Handshake`() {
+ val json =
+ """{"pluginVersion":"0.10.0","protocolMajor":1,"protocolMinor":0,"protocolPatch":0,"futureField":"value"}"""
+ val data = buildRawMessage("handshake", json)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.Handshake>(decoded)
+ assertEquals("0.10.0", decoded.pluginVersion)
+ }
+
+ @Test
+ fun `current codec ignores unknown fields in GlobalChatMessage`() {
+ val json =
+ """{"messageId":"abc","serverName":"lobby","playerId":"pid","playerName":"Test","message":"Hello","timestamp":1000,"futureField":"value","anotherField":42}"""
+ val data = buildRawMessage("global_chat", json)
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.GlobalChatMessage>(decoded)
+ assertEquals("abc", decoded.messageId)
+ assertEquals("Hello", decoded.message)
+ }
+
+ @Test
+ fun `StatusRequest sub-channel decodes correctly with empty JSON`() {
+ val data = buildRawMessage("status_request", "{}")
+ val decoded = PluginMessageCodec.decode(data)
+
+ assertIs<PluginMessage.StatusRequest>(decoded)
+ }
+}
diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersionTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersionTest.kt
index 7f05039..ad68beb 100644
--- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersionTest.kt
+++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/protocol/ProtocolVersionTest.kt
@@ -54,4 +54,24 @@ class ProtocolVersionTest {
fun `isCompatible string with two parts should return false`() {
assertFalse(ProtocolVersion.isCompatible("1.0"))
}
+
+ @Test
+ fun `MIN_SUPPORTED_MINOR should be less than or equal to MINOR`() {
+ assertTrue(ProtocolVersion.MIN_SUPPORTED_MINOR <= ProtocolVersion.MINOR)
+ }
+
+ @Test
+ fun `isCompatible with minor equal to MINOR should return true`() {
+ assertTrue(ProtocolVersion.isCompatible(ProtocolVersion.MAJOR, ProtocolVersion.MINOR))
+ }
+
+ @Test
+ fun `isCompatible with minor equal to MIN_SUPPORTED_MINOR should return true`() {
+ assertTrue(ProtocolVersion.isCompatible(ProtocolVersion.MAJOR, ProtocolVersion.MIN_SUPPORTED_MINOR))
+ }
+
+ @Test
+ fun `isCompatible with minor above MINOR should return false`() {
+ assertFalse(ProtocolVersion.isCompatible(ProtocolVersion.MAJOR, ProtocolVersion.MINOR + 1))
+ }
}