diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-08-30 20:43:18 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-08-30 20:43:18 +0900 |
| commit | 64197733346bbcabc3daa65f6648c1a21e26b328 (patch) | |
| tree | 63d67ead1a9cd7460096e5dfa2977844d9f2e564 /engine/src | |
| parent | 44132f3a73651fbd334a314fbc4ab47f5f1bbd5b (diff) | |
| download | LunaticChat-64197733346bbcabc3daa65f6648c1a21e26b328.tar.gz LunaticChat-64197733346bbcabc3daa65f6648c1a21e26b328.tar.bz2 LunaticChat-64197733346bbcabc3daa65f6648c1a21e26b328.zip | |
feat(engine): give both platforms one debug switch to read
"Debug" had grown into three unconnected mechanisms: a boolean injected into
RomanjiConverter, a one-off configuration dump at startup, and KtorSimpleLogger
calls that Paper's stock log4j configuration never printed. Turning `debug: true`
on told an operator nothing about the Velocity handshake, which is what #228 is
actually about.
The vocabulary goes in engine because it is a contract before it is an
implementation: config.yml and the proxy's system property have to agree on what
`velocity,protocol` means, and a category added on one side must exist on the
other. Only the writing is left to the platforms.
The message is a lambda so that the chat path - instrumented once per message -
costs nothing while debugging is off. The `if (debugMode) logger.info("... $word")`
guards this replaces built their strings either way.
Both platform loggers write at INFO rather than at the level the content
deserves, because neither Paper's nor Velocity's stock logging configuration
prints anything below it, and asking operators to edit log4j before they can
answer a bug report is the friction this exists to remove.
Refs #228
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'engine/src')
6 files changed, 268 insertions, 0 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..3231295 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategories.kt @@ -0,0 +1,59 @@ +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() + } + + 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 -> + 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..9c12a92 --- /dev/null +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategory.kt @@ -0,0 +1,29 @@ +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 } + + /** 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/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..44ccb86 --- /dev/null +++ b/engine/src/test/kotlin/dev/m1sk9/lunaticChat/engine/debug/DebugCategoriesTest.kt @@ -0,0 +1,76 @@ +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 `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) + } +} |
