diff options
Diffstat (limited to 'engine/src')
11 files changed, 334 insertions, 3 deletions
diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategories.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategories.kt new file mode 100644 index 0000000..c3d7d95 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategories.kt @@ -0,0 +1,70 @@ +package dev.m1sk9.lunaticChat.engine.debug + +/** + * Reads a debug switch written as text. + * + * Shared by both platforms so that `debug: velocity,protocol` in config.yml and + * `-Dlunaticchat.debug=velocity,protocol` on the proxy mean the same thing. A grammar owned by one + * side would drift from the other the first time a category was added. + */ +object DebugCategories { + /** What a debug switch asked for. */ + data class Parsed( + val enabled: Boolean, + val categories: Set<DebugCategory>, + /** Names that match no category, reported to the operator rather than thrown. */ + val unknown: List<String>, + ) { + /** The categories that actually log. Empty while [enabled] is false, whatever was listed. */ + val active: Set<DebugCategory> get() = if (enabled) categories else emptySet() + } + + /** The word that stands for every category at once, wherever a category may be named. */ + const val ALL = "all" + + private val allWords = setOf("true", "yes", "on", "y", ALL) + private val noneWords = setOf("false", "no", "off", "n", "none") + + /** + * Reads a whole switch: a boolean spelling, or a comma-separated list of category keys. + * + * The boolean spellings match the ones config.yml already accepts elsewhere: `debug: yes` + * worked while Bukkit read the file as YAML 1.1, and reading it as a category name instead + * would silently switch debugging off for a file that had it on. + */ + fun parse(raw: String): Parsed { + val trimmed = raw.trim() + val word = trimmed.lowercase() + return when { + trimmed.isEmpty() || word in noneWords -> Parsed(false, DebugCategory.entries.toSet(), emptyList()) + word in allWords -> Parsed(true, DebugCategory.entries.toSet(), emptyList()) + else -> resolve(trimmed.split(',')) + } + } + + /** Reads category keys that arrived already separated, as config.yml's `categories:` list does. */ + fun resolve(names: Iterable<String>): Parsed { + val categories = LinkedHashSet<DebugCategory>() + val unknown = mutableListOf<String>() + + names + .map { it.trim() } + .filter { it.isNotEmpty() } + .forEach { name -> + // [ALL] is honoured here as well as in [parse] and /lc debug: an operator who has + // read `debug: all` or `/lc debug all on` writes `categories: [all]` next, and + // reporting that as an unknown name would leave them with a switch that logs + // nothing while saying it is on. + if (name.equals(ALL, ignoreCase = true)) { + categories += DebugCategory.entries + return@forEach + } + val category = DebugCategory.fromKey(name) + if (category == null) unknown += name else categories += category + } + + // A switch that named nothing this build knows is off rather than on-with-no-categories: + // reporting it as enabled would put "debug is on" in /lc status for a typo that logs nothing. + return Parsed(enabled = categories.isNotEmpty(), categories = categories, unknown = unknown) + } +} diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategory.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategory.kt new file mode 100644 index 0000000..40cc0e8 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategory.kt @@ -0,0 +1,32 @@ +package dev.m1sk9.lunaticChat.engine.debug + +/** + * The area of the plugin a debug line belongs to. + * + * Categories exist so that instrumenting the chat path - which runs once per message - does not + * make the log unusable for someone chasing a handshake. [key] is the spelling operators write in + * config.yml and on the Velocity command line; the enum name is never exposed to them. + */ +enum class DebugCategory( + val key: String, +) { + CONFIG("config"), + CHAT("chat"), + CHANNEL("channel"), + CONVERSION("conversion"), + PROTOCOL("protocol"), + VELOCITY("velocity"), + STORAGE("storage"), + COMMAND("command"), + ; + + companion object { + private val byKey = entries.associateBy { it.key } + + /** Every [key], for the "known categories are ..." half of a message about a typo. */ + val keyList: String = entries.joinToString(", ") { it.key } + + /** The category [key] names, or null when no category is spelled that way. */ + fun fromKey(key: String): DebugCategory? = byKey[key.trim().lowercase()] + } +} diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugLogger.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugLogger.kt new file mode 100644 index 0000000..5f235f6 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugLogger.kt @@ -0,0 +1,27 @@ +package dev.m1sk9.lunaticChat.engine.debug + +/** + * Writes a diagnostic line, but only for the categories that are switched on. + * + * [message] is a lambda rather than a String so that instrumenting a path taken once per chat + * message costs nothing while debugging is off: the interpolation never runs. The + * `if (debugMode) logger.info("... $word")` guards this replaced paid for their strings either way. + */ +interface DebugLogger { + fun isEnabled(category: DebugCategory): Boolean + + fun log( + category: DebugCategory, + message: () -> String, + ) + + /** A logger for callers that have no debug state to consult, such as tests of unrelated code. */ + object Disabled : DebugLogger { + override fun isEnabled(category: DebugCategory): Boolean = false + + override fun log( + category: DebugCategory, + message: () -> String, + ) = Unit + } +} diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugState.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugState.kt new file mode 100644 index 0000000..b4fe9db --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugState.kt @@ -0,0 +1,32 @@ +package dev.m1sk9.lunaticChat.engine.debug + +/** + * The debug categories currently switched on, which a reload or `/lc debug` may replace while the + * server runs. + * + * Held apart from the configuration for the same reason the message formats are: taking this type + * says the value is live, while taking a configuration says it was frozen at startup. Every writer + * replaces the whole set under a lock, so a reader never sees a half-built one. + */ +class DebugState( + initial: Set<DebugCategory> = emptySet(), +) { + @Volatile + var enabled: Set<DebugCategory> = initial.toSet() + private set + + fun isEnabled(category: DebugCategory): Boolean = category in enabled + + @Synchronized + fun replace(categories: Set<DebugCategory>) { + enabled = categories.toSet() + } + + @Synchronized + fun set( + category: DebugCategory, + on: Boolean, + ) { + enabled = if (on) enabled + category else enabled - category + } +} diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt index 6291553..9831b3c 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNode.kt @@ -13,6 +13,12 @@ sealed class LunaticChatPermissionNode( object Status : LunaticChatPermissionNode("lunaticchat.command.lc.status") + object Reload : LunaticChatPermissionNode("lunaticchat.command.lc.reload") + + object Debug : LunaticChatPermissionNode("lunaticchat.command.lc.debug") + + object Dump : LunaticChatPermissionNode("lunaticchat.command.lc.dump") + object Channel : LunaticChatPermissionNode("lunaticchat.command.lc.channel") object ChannelCreate : LunaticChatPermissionNode("lunaticchat.command.lc.channel.create") 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 689a57c..82cff37 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 @@ -93,6 +93,8 @@ sealed interface PluginMessage { * @property targetName Target player name * @property message Message content (romaji-converted if applicable) * @property timestamp Message timestamp (milliseconds since epoch) + * @property senderWorld Name of the world the sender is in; empty when relayed by a + * pre-1.0.2 peer */ @Serializable data class DirectMessageRelay( @@ -104,6 +106,7 @@ sealed interface PluginMessage { val targetName: String, val message: String, val timestamp: Long = System.currentTimeMillis(), + val senderWorld: String = "", ) : 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 6585c86..cdf17e7 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 = 1 + const val PATCH = 2 /** * Minimum MINOR version this build can interoperate with (same MAJOR). diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategoriesTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategoriesTest.kt new file mode 100644 index 0000000..5931c60 --- /dev/null +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategoriesTest.kt @@ -0,0 +1,93 @@ +package dev.m1sk9.lunaticChat.engine.debug + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebugCategoriesTest { + @Test + fun `a true spelling switches every category on`() { + listOf("true", "TRUE", "yes", "on", "all", " true ").forEach { raw -> + val parsed = DebugCategories.parse(raw) + + assertTrue(parsed.enabled, raw) + assertEquals(DebugCategory.entries.toSet(), parsed.active, raw) + } + } + + @Test + fun `a false spelling switches every category off`() { + listOf("false", "FALSE", "no", "off", "none", "", " ").forEach { raw -> + val parsed = DebugCategories.parse(raw) + + assertFalse(parsed.enabled, raw) + assertEquals(emptySet(), parsed.active, raw) + } + } + + @Test + fun `a comma separated list switches on exactly the categories it names`() { + val parsed = DebugCategories.parse("velocity,protocol") + + assertTrue(parsed.enabled) + assertEquals(setOf(DebugCategory.VELOCITY, DebugCategory.PROTOCOL), parsed.active) + assertEquals(emptyList(), parsed.unknown) + } + + @Test + fun `surrounding whitespace, casing and empty entries are ignored`() { + val parsed = DebugCategories.parse(" Velocity , , PROTOCOL ,") + + assertEquals(setOf(DebugCategory.VELOCITY, DebugCategory.PROTOCOL), parsed.active) + assertEquals(emptyList(), parsed.unknown) + } + + @Test + fun `an unknown name is reported without discarding the categories beside it`() { + val parsed = DebugCategories.parse("velocity,proxy") + + assertEquals(setOf(DebugCategory.VELOCITY), parsed.active) + assertEquals(listOf("proxy"), parsed.unknown) + } + + @Test + fun `a switch naming only unknown categories logs nothing`() { + val parsed = DebugCategories.parse("maybe") + + assertEquals(emptySet(), parsed.active) + assertEquals(listOf("maybe"), parsed.unknown) + } + + @Test + fun `resolve reads a list the way parse reads the same names spelled with commas`() { + assertEquals( + DebugCategories.parse("velocity,protocol"), + DebugCategories.resolve(listOf("velocity", "protocol")), + ) + } + + @Test + fun `all names every category wherever a category may be named`() { + // The same word works as `debug: all`, as `categories: [all]` and as `/lc debug all on`; + // reading it as a category name in only two of the three would log nothing for the third. + assertEquals(DebugCategory.entries.toSet(), DebugCategories.parse("all").active) + assertEquals(DebugCategory.entries.toSet(), DebugCategories.resolve(listOf("all")).active) + assertEquals(emptyList(), DebugCategories.resolve(listOf("ALL")).unknown) + } + + @Test + fun `all beside a named category still names every category`() { + val parsed = DebugCategories.resolve(listOf("velocity", "all")) + + assertEquals(DebugCategory.entries.toSet(), parsed.active) + assertEquals(emptyList(), parsed.unknown) + } + + @Test + fun `every category is reachable by the key operators write`() { + DebugCategory.entries.forEach { category -> + assertEquals(category, DebugCategory.fromKey(category.key)) + } + } +} diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugStateTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugStateTest.kt new file mode 100644 index 0000000..ae1456d --- /dev/null +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugStateTest.kt @@ -0,0 +1,45 @@ +package dev.m1sk9.lunaticChat.engine.debug + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebugStateTest { + @Test + fun `a state starts on the categories it was built with`() { + val state = DebugState(setOf(DebugCategory.VELOCITY)) + + assertTrue(state.isEnabled(DebugCategory.VELOCITY)) + assertFalse(state.isEnabled(DebugCategory.CHAT)) + } + + @Test + fun `replace drops the categories it does not list`() { + val state = DebugState(setOf(DebugCategory.VELOCITY, DebugCategory.CHAT)) + + state.replace(setOf(DebugCategory.PROTOCOL)) + + assertEquals(setOf(DebugCategory.PROTOCOL), state.enabled) + } + + @Test + fun `set switches one category without touching the others`() { + val state = DebugState(setOf(DebugCategory.VELOCITY)) + + state.set(DebugCategory.CHAT, on = true) + state.set(DebugCategory.VELOCITY, on = false) + + assertEquals(setOf(DebugCategory.CHAT), state.enabled) + } + + @Test + fun `a state built from a set is unaffected by later changes to it`() { + val categories = mutableSetOf(DebugCategory.VELOCITY) + val state = DebugState(categories) + + categories += DebugCategory.CHAT + + assertEquals(setOf(DebugCategory.VELOCITY), state.enabled) + } +} diff --git a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt index d6d74f7..e5e43cb 100644 --- a/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/permission/LunaticChatPermissionNodeTest.kt @@ -12,6 +12,9 @@ class LunaticChatPermissionNodeTest { LunaticChatPermissionNode.Reply, LunaticChatPermissionNode.Settings, LunaticChatPermissionNode.Status, + LunaticChatPermissionNode.Reload, + LunaticChatPermissionNode.Debug, + LunaticChatPermissionNode.Dump, LunaticChatPermissionNode.Channel, LunaticChatPermissionNode.ChannelCreate, LunaticChatPermissionNode.ChannelList, @@ -44,8 +47,8 @@ class LunaticChatPermissionNodeTest { } @Test - fun `should have 24 permission nodes`() { - assertEquals(24, allNodes.size) + fun `should have 27 permission nodes`() { + assertEquals(27, allNodes.size) } @Test @@ -54,6 +57,9 @@ class LunaticChatPermissionNodeTest { assertEquals("lunaticchat.command.tell", LunaticChatPermissionNode.Tell.permissionNode) assertEquals("lunaticchat.command.reply", LunaticChatPermissionNode.Reply.permissionNode) assertEquals("lunaticchat.command.lc.settings", LunaticChatPermissionNode.Settings.permissionNode) + assertEquals("lunaticchat.command.lc.reload", LunaticChatPermissionNode.Reload.permissionNode) + assertEquals("lunaticchat.command.lc.debug", LunaticChatPermissionNode.Debug.permissionNode) + assertEquals("lunaticchat.command.lc.dump", LunaticChatPermissionNode.Dump.permissionNode) assertEquals("lunaticchat.command.lc.channel.create", LunaticChatPermissionNode.ChannelCreate.permissionNode) assertEquals("lunaticchat.command.lcv.status", LunaticChatPermissionNode.VelocityStatus.permissionNode) assertEquals("lunaticchat.spy", LunaticChatPermissionNode.Spy.permissionNode) 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 e70edda..095ecd1 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 @@ -42,6 +42,10 @@ class ProtocolBackwardCompatibilityTest { const val PRESENCE_SNAPSHOT_V1_0_1 = """{"players":[{"playerName":"Alice","serverName":"lobby"},{"playerName":"Bob","serverName":"survival"}],"timestamp":5000}""" + + // Protocol 1.0.2 snapshots — NEVER MODIFY after commit + const val DIRECT_MESSAGE_V1_0_2 = + """{"messageId":"dm-321","sourceServerName":"survival","senderId":"00000007-0000-0000-0000-000000000000","senderName":"Sender","targetServerName":"lobby","targetName":"Recipient","message":"Hello!","timestamp":6000,"senderWorld":"world_nether"}""" } private fun buildRawMessage( @@ -162,6 +166,19 @@ class ProtocolBackwardCompatibilityTest { assertEquals("Recipient", decoded.targetName) assertEquals("Hello!", decoded.message) assertEquals(4000L, decoded.timestamp) + assertEquals("", decoded.senderWorld) + } + + @Test + fun `current codec can decode protocol 1_0_2 DirectMessageRelay`() { + val data = buildRawMessage("direct_message", DIRECT_MESSAGE_V1_0_2) + val decoded = PluginMessageCodec.decode(data) + + assertIs<PluginMessage.DirectMessageRelay>(decoded) + assertEquals("dm-321", decoded.messageId) + assertEquals("Sender", decoded.senderName) + assertEquals("Hello!", decoded.message) + assertEquals("world_nether", decoded.senderWorld) } @Test |
