diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-04-04 20:26:37 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-04-04 20:26:37 +0900 |
| commit | 2b0829856c9a24b90a9248d28c7de84101b83232 (patch) | |
| tree | 63160192d490543ae7c1fc4a16286397da8261ee /engine | |
| parent | 9027d9f04fb833f428d6ef528cb19542cef8bd49 (diff) | |
| download | LunaticChat-2b0829856c9a24b90a9248d28c7de84101b83232.tar.gz LunaticChat-2b0829856c9a24b90a9248d28c7de84101b83232.tar.bz2 LunaticChat-2b0829856c9a24b90a9248d28c7de84101b83232.zip | |
feat: Improving Velocity's Cycling Compatibility
Diffstat (limited to 'engine')
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)) + } } |
