From 0c231c8e766efbc83c2f548a3e5cc0f5d25708da Mon Sep 17 00:00:00 2001 From: Liam Sage Date: Wed, 4 Nov 2026 12:21:37 +0100 Subject: [PATCH 1/2] feat: configure per-server messages and match-end triggers --- .github/workflows/build.yml | 21 +++ README.md | 35 ++++- .../cc/modlabs/autogg/AutoGgConfigEditor.kt | 64 ++++++++ .../kotlin/cc/modlabs/autogg/AutoGgModMenu.kt | 143 ++++++++++-------- .../cc/modlabs/autogg/AutoGgSettings.kt | 64 ++++++-- src/main/kotlin/cc/modlabs/autogg/Autogg.kt | 8 +- .../kotlin/cc/modlabs/autogg/MessageLists.kt | 40 +++++ .../cc/modlabs/autogg/chat/EventDetector.kt | 17 ++- .../cc/modlabs/autogg/AutoGgEditorTest.kt | 128 ++++++++++++++++ .../kotlin/cc/modlabs/autogg/AutoGgTest.kt | 12 ++ .../cc/modlabs/autogg/EventDetectorTest.kt | 74 +++++++++ .../cc/modlabs/autogg/TriggerSettingsTest.kt | 88 +++++++++++ 12 files changed, 602 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt create mode 100644 src/main/kotlin/cc/modlabs/autogg/MessageLists.kt create mode 100644 src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt create mode 100644 src/test/kotlin/cc/modlabs/autogg/EventDetectorTest.kt create mode 100644 src/test/kotlin/cc/modlabs/autogg/TriggerSettingsTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a353d52 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,21 @@ +name: Build and test + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '25' + - name: Build and test + run: ./gradlew --no-daemon clean build diff --git a/README.md b/README.md index 6164503..73ffa1f 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,39 @@ Compatible with popular servers such as Hypixel, BedwarsPractice, PvP Land, Mine Simple to install and lightweight, it runs seamlessly in the background, letting you focus on your game. Elevate your gameplay and foster a friendly community with Auto GG! -Open Auto GG's settings in Mod Menu to set default messages and server overrides. Enter a hostname such as `hypixel.net`, then use **Load**, **Save**, or **Remove override**. Subdomains use the most specific matching hostname; unmatched servers use the defaults. Separate multiple messages with ` | `; use `\|` for a literal pipe and `\\` for a literal backslash. One message is chosen at random when a game ends. +## Per-server settings -Use `/autogg ` in game to replace the default list with one message, or `/autogg` to view its first message. Existing server overrides remain unchanged. The default is `gg`. -On Hypixel, Auto GG avoids repeating the last sent chat message when a different configured message is available. Otherwise it skips sending until another chat message is sent, reducing duplicate-message warnings. +Open Auto GG's settings in Mod Menu. Enter a hostname, such as `hypixel.net`, and select **Load** before editing it. A blank hostname edits the default outgoing messages. Switch between **Messages** and **End triggers**, choose **Customize**, edit the list, then **Save**. **Done** closes the screen without saving unsaved edits. Both tabs are saved together. -Overrides do not add game-end detection: Auto GG only sends on the supported servers listed above. Adding an override for another server, such as `modlabs.cc`, stores the messages but does not enable game-end detection there. +Messages and triggers inherit independently from the most specific matching hostname, then fall back to default messages and built-in detection. For example, a message override for `play.example.net` can still use triggers configured for `example.net`. Hostname matching ignores case and the connection's port. Enter saved hostnames without a port. + +- **Messages:** separate alternatives with ` | `. One is chosen at random when a game ends. Messages are trimmed and must be 1–256 characters, without commands, control characters or formatting codes. +- **End triggers:** separate literal snippets with `|`. Matching is case-sensitive, and **spaces are significant**, including spaces next to the separator. A match anywhere in a received game/system message ends the game. Each snippet must be 1–1024 characters, without control characters or formatting codes. These are plain text, not regular expressions. +- In either editor, use `\|` for a literal pipe and `\\` for a literal backslash. +- Custom triggers **replace** that hostname's built-in detection. A completely empty custom trigger list explicitly disables detection. **Reset to inherited detection** removes the custom override after you select **Save**; this may reveal a parent-domain override or the server's built-in detector. Resetting messages does not reset triggers, and vice versa. +- Unsupported servers, including mcpvp.club and PvP Legacy, remain inactive until you enter reliable end-message snippets from that server. No guessed presets are supplied. Capture the actual system/game chat text; title-only or scoreboard-only results are not detected. + +The built-in servers remain Hypixel, BedwarsPractice, PvP Land, Minemen, and Mineplex. Their existing behavior is unchanged unless custom triggers apply. Repeated end messages within three seconds are suppressed. Hypixel additionally avoids repeating the last sent chat message when another configured message is available, or skips sending until another chat message is sent. + +Use `/autogg ` in game to replace the default message list with one message, or `/autogg` to view its first message. Server messages and triggers remain unchanged. The default is `gg`. + +### Saved configuration + +Settings are stored locally in Minecraft's Fabric config directory as `autogg.json` and loaded at startup. Mod Menu changes apply immediately after saving. Existing message-only JSON and the older `autogg-message.txt` setting remain supported. Unreadable settings are not overwritten. + +```json +{ + "default": ["gg"], + "servers": { + "example.net": ["gg", "well played"] + }, + "triggers": { + "example.net": ["YOUR VERIFIED END MESSAGE"] + } +} +``` + +The example is illustrative, not a real server preset. The optional `triggers` map is independent of `servers`. An absent hostname inherits detection; `"example.net": []` disables detection for that hostname and its subdomains unless a more specific override exists. Restart Minecraft after manual file edits. Back up the file before switching to an older AutoGG build, which does not understand trigger overrides. ## Publishing diff --git a/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt b/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt new file mode 100644 index 0000000..65e20c1 --- /dev/null +++ b/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt @@ -0,0 +1,64 @@ +package cc.modlabs.autogg + +import cc.modlabs.autogg.chat.EventDetector + +/** Unsaved editor state, separate from Minecraft widgets so scope and save behavior can be tested. */ +internal class AutoGgConfigEditor(private val settings: AutoGgSettings) { + private val detector = EventDetector() + var server = "" + private set + var messages = formatMessages(settings.defaultMessages) + var triggers = "" + var customMessages = true + private set + var customTriggers = false + private set + val isDefault: Boolean get() = server.isEmpty() + + fun load(hostname: String) { + val name = key(hostname) + server = name + customMessages = isDefault || settings.overrides.containsKey(name) + customTriggers = !isDefault && settings.triggerOverrides.containsKey(name) + messages = formatMessages(settings.messagesFor(name)) + triggers = if (isDefault) "" else formatTriggers(settings.triggersFor(name) ?: builtInTriggers()) + } + + fun toggleMessages() { + require(!isDefault) { "Default messages cannot inherit from another setting." } + customMessages = !customMessages + if (!customMessages) messages = formatMessages( + matchingOverride(server, settings.overrides - server) ?: settings.defaultMessages + ) + } + + fun toggleTriggers() { + require(!isDefault) { "Enter and load a server hostname to configure detection." } + customTriggers = !customTriggers + if (!customTriggers) triggers = formatTriggers(inheritedTriggers() ?: builtInTriggers()) + } + + fun save(hostname: String) { + require(key(hostname) == server) { "Load the new hostname before editing or saving its settings." } + if (isDefault) { + settings.saveDefaults(parseMessages(messages)) + } else { + settings.saveServerConfig(server, + if (customMessages) parseMessages(messages) else null, + if (customTriggers) parseTriggers(triggers) else null) + } + } + + fun detectionDescription(): String = when { + isDefault -> "Detection is configured per server. Enter a hostname and select Load." + customTriggers && triggers.isEmpty() -> "Custom detection is disabled: an empty list matches nothing." + customTriggers -> "Custom triggers replace built-in rules. Save to apply." + inheritedTriggers() != null -> "Using parent-domain triggers. Customize to override them." + builtInTriggers().isNotEmpty() -> "Using built-in detection. Customize to replace it." + else -> "No detection configured. Customize and enter real server end text." + } + + private fun inheritedTriggers(): List? = matchingOverride(server, settings.triggerOverrides - server) + private fun builtInTriggers(): List = detector.builtInTriggers(ServerConfig.entries.firstOrNull { it.matches(server) }) + private fun key(hostname: String): String = if (hostname.isBlank()) "" else normalizeServer(hostname) +} diff --git a/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt b/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt index 449b138..4e7fc3b 100644 --- a/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt +++ b/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt @@ -15,94 +15,109 @@ class AutoGgModMenu : ModMenuApi { } } -private class AutoGgConfigScreen(private val parent: Screen, private val settings: AutoGgSettings) : +private class AutoGgConfigScreen(private val parent: Screen, settings: AutoGgSettings) : Screen(Component.literal("Auto GG settings")) { + private val editor = AutoGgConfigEditor(settings) private lateinit var server: EditBox - private lateinit var messages: EditBox - private var status = "" + private lateinit var values: EditBox + private lateinit var mode: Button + private lateinit var messagesTab: Button + private lateinit var triggersTab: Button + private var showTriggers = false + private var refreshing = false + private var status = "Enter a hostname and Load, or edit the default messages below." override fun init() { val x = width / 2 - 150 - val currentServer = if (::server.isInitialized) server.value else "" - val currentMessages = if (::messages.isInitialized) messages.value else formatMessages(settings.defaultMessages) - server = addRenderableWidget(EditBox(font, x, 48, 300, 20, Component.literal("Server hostname, blank for default"))) + val hostname = if (::server.isInitialized) server.value else editor.server + server = addRenderableWidget(EditBox(font, x, 42, 198, 20, Component.literal("Server hostname, blank for default"))) server.setMaxLength(253) - server.setHint(Component.literal("Blank = default; e.g. hypixel.net")) - server.value = currentServer - messages = addRenderableWidget(EditBox(font, x, 92, 300, 20, Component.literal("Messages, separated by |"))) - messages.setMaxLength(8192) - messages.value = currentMessages + server.setHint(Component.literal("Blank = default messages")) + server.value = hostname + server.setResponder { status = "Select Load before editing a different hostname." } + addRenderableWidget(Button.builder(Component.literal("Load")) { + attempt { + editor.load(server.value) + refreshFields() + status = if (showTriggers) editor.detectionDescription() else "Loaded. Save to apply your changes." + } + }.bounds(x + 204, 42, 96, 20).build()) - addRenderableWidget(Button.builder(Component.literal("Load")) { load() }.bounds(x, 122, 95, 20).build()) - addRenderableWidget(Button.builder(Component.literal("Save")) { save() }.bounds(x + 102, 122, 95, 20).build()) - addRenderableWidget(Button.builder(Component.literal("Remove override")) { remove() }.bounds(x + 204, 122, 96, 20).build()) - addRenderableWidget(Button.builder(Component.literal("Done")) { onClose() }.bounds(x + 100, 192, 100, 20).build()) - } + messagesTab = addRenderableWidget(Button.builder(Component.literal("Messages")) { + showTriggers = false + refreshFields() + status = "One message is chosen at random. Use \\| for a literal pipe." + }.bounds(x, 68, 147, 20).build()) + triggersTab = addRenderableWidget(Button.builder(Component.literal("End triggers")) { + showTriggers = true + refreshFields() + status = editor.detectionDescription() + }.bounds(x + 153, 68, 147, 20).build()) + + values = addRenderableWidget(EditBox(font, x, 106, 300, 20, Component.literal("Configured values, separated by pipes"))) + values.setMaxLength(8192) + values.setResponder { value -> + if (!refreshing) { + if (showTriggers) editor.triggers = value else editor.messages = value + status = if (showTriggers && value.isEmpty()) "Empty custom triggers disable detection. Save to apply." else "Unsaved changes. Select Save to apply." + } + } + mode = addRenderableWidget(Button.builder(Component.literal("Customize")) { + attempt { + if (showTriggers) editor.toggleTriggers() else editor.toggleMessages() + refreshFields() + status = if (showTriggers) editor.detectionDescription() else "Message inheritance changed. Save to apply." + } + }.bounds(x, 132, 300, 20).build()) - private fun load() { - val key = server.value.trim().lowercase().trimEnd('.') - val values = if (key.isEmpty()) settings.defaultMessages else settings.overrides[key] - messages.value = values?.let(::formatMessages) ?: "" - status = if (values == null) "No override yet; enter messages and save." else "Loaded." + addRenderableWidget(Button.builder(Component.literal("Save")) { + attempt { + editor.save(server.value) + status = "Saved ${if (editor.isDefault) "default messages" else "settings for ${editor.server}"}." + } + }.bounds(x, height - 30, 147, 20).build()) + addRenderableWidget(Button.builder(Component.literal("Done")) { onClose() } + .bounds(x + 153, height - 30, 147, 20).build()) + refreshFields() } - private fun save() { - try { - val values = parseMessages(messages.value) - if (server.value.isBlank()) settings.saveDefaults(values) else settings.saveServer(server.value, values) - status = "Saved." - } catch (e: IllegalArgumentException) { - status = e.message ?: "Invalid settings." - } catch (e: IOException) { - status = "Could not save settings: ${e.message}" - } + private fun refreshFields() { + refreshing = true + values.value = if (showTriggers) editor.triggers else editor.messages + values.setEditable(if (showTriggers) editor.customTriggers else editor.customMessages) + mode.active = !editor.isDefault + mode.message = Component.literal(when { + editor.isDefault -> "Load a hostname for per-server overrides" + showTriggers && editor.customTriggers -> "Reset to inherited detection" + showTriggers -> "Customize detection" + editor.customMessages -> "Reset to inherited messages" + else -> "Customize messages" + }) + messagesTab.message = Component.literal(if (showTriggers) "Messages" else "[ Messages ]") + triggersTab.message = Component.literal(if (showTriggers) "[ End triggers ]" else "End triggers") + refreshing = false } - private fun remove() { + private fun attempt(action: () -> Unit) { try { - require(server.value.isNotBlank()) { "Enter a server to remove its override." } - settings.saveServer(server.value, emptyList()) - messages.value = "" - status = "Override removed; the default will be used." + action() } catch (e: IllegalArgumentException) { - status = e.message ?: "Invalid server." + status = e.message ?: "Invalid settings." } catch (e: IOException) { - status = "Could not save settings: ${e.message}" + status = "Could not save: ${e.message}" } } override fun extractRenderState(graphics: GuiGraphicsExtractor, mouseX: Int, mouseY: Int, delta: Float) { super.extractRenderState(graphics, mouseX, mouseY, delta) val x = width / 2 - 150 - graphics.centeredText(font, title, width / 2, 20, -1) - graphics.text(font, "Server hostname (blank for default)", x, 36, -1) - graphics.text(font, "Messages: separate with | (escape as \\|)", x, 80, -1) - graphics.textWithWordWrap(font, Component.literal(status), x, 148, 300, -1) - graphics.text(font, "Overrides do not add server detection.", x, 176, -1) + graphics.centeredText(font, title, width / 2, 12, -1) + graphics.text(font, "Server hostname (blank for default messages)", x, 30, -1) + graphics.text(font, if (showTriggers) "Triggers: | separated; case and spaces matter" else "Messages: separate with | (escape as \\|)", x, 94, -1) + graphics.textWithWordWrap(font, Component.literal(status.take(140)), x, 160, 300, -1) } override fun onClose() { minecraft.setScreenAndShow(parent) } } - -internal fun formatMessages(messages: List): String = - messages.joinToString(" | ") { it.replace("\\", "\\\\").replace("|", "\\|") } - -internal fun parseMessages(text: String): List { - require(text.isNotBlank()) { "Enter at least one message." } - val result = mutableListOf() - val item = StringBuilder() - var escaping = false - for (char in text) { - when { - escaping -> { require(char == '\\' || char == '|') { "Use \\\\ or \\| for literal characters." }; item.append(char); escaping = false } - char == '\\' -> escaping = true - char == '|' -> { result.add(item.toString().trim()); item.clear() } - else -> item.append(char) - } - } - require(!escaping) { "Incomplete escape at the end of the messages." } - result.add(item.toString().trim()) - return result -} diff --git a/src/main/kotlin/cc/modlabs/autogg/AutoGgSettings.kt b/src/main/kotlin/cc/modlabs/autogg/AutoGgSettings.kt index da0b7d7..21ed21a 100644 --- a/src/main/kotlin/cc/modlabs/autogg/AutoGgSettings.kt +++ b/src/main/kotlin/cc/modlabs/autogg/AutoGgSettings.kt @@ -14,6 +14,8 @@ class AutoGgSettings(private val path: Path) { private set var overrides: Map> = emptyMap() private set + var triggerOverrides: Map> = emptyMap() + private set val message: String get() = defaultMessages.first() private var loadFailed = false @@ -25,10 +27,17 @@ class AutoGgSettings(private val path: Path) { val servers = json.getAsJsonObject("servers").entrySet().associate { entry -> normalizeServer(entry.key) to entry.value.asJsonArray.map { it.asString } } + val triggers = if (json.has("triggers")) { + json.getAsJsonObject("triggers").entrySet().associate { entry -> + normalizeServer(entry.key) to entry.value.asJsonArray.map { it.asString } + } + } else emptyMap() + triggers.values.forEach(::validateTriggers) validateMessages(defaults) servers.values.forEach(::validateMessages) defaultMessages = defaults overrides = servers + triggerOverrides = triggers } else { val legacy = path.resolveSibling("autogg-message.txt") if (Files.exists(legacy)) { @@ -43,18 +52,17 @@ class AutoGgSettings(private val path: Path) { } } - fun messagesFor(address: String?): List { - val host = address?.lowercase()?.substringBefore(':')?.trimEnd('.') ?: return defaultMessages - return overrides.entries.filter { (server, _) -> host == server || host.endsWith(".$server") } - .maxByOrNull { it.key.length }?.value ?: defaultMessages - } + fun messagesFor(address: String?): List = + matchingOverride(address, overrides) ?: defaultMessages + + fun triggersFor(address: String?): List? = matchingOverride(address, triggerOverrides) fun save(value: String) = saveDefaults(listOf(value)) fun saveDefaults(messages: List) { val cleaned = messages.map(String::trim) validateMessages(cleaned) - write(cleaned, overrides) + write(cleaned, overrides, triggerOverrides) defaultMessages = cleaned } @@ -64,16 +72,32 @@ class AutoGgSettings(private val path: Path) { if (cleaned.isNotEmpty()) validateMessages(cleaned) val updated = overrides.toMutableMap() if (cleaned.isEmpty()) updated.remove(name) else updated[name] = cleaned - write(defaultMessages, updated) + write(defaultMessages, updated, triggerOverrides) overrides = updated } - private fun write(defaults: List, servers: Map>) { + /** Null removes that field's override; an empty trigger list explicitly disables detection. */ + fun saveServerConfig(server: String, messages: List?, triggers: List?) { + val name = normalizeServer(server) + val cleaned = messages?.map(String::trim) + cleaned?.let(::validateMessages) + triggers?.let(::validateTriggers) + val updatedMessages = overrides.toMutableMap() + val updatedTriggers = triggerOverrides.toMutableMap() + if (cleaned == null) updatedMessages.remove(name) else updatedMessages[name] = cleaned + if (triggers == null) updatedTriggers.remove(name) else updatedTriggers[name] = triggers.toList() + write(defaultMessages, updatedMessages, updatedTriggers) + overrides = updatedMessages + triggerOverrides = updatedTriggers + } + + private fun write(defaults: List, servers: Map>, triggers: Map>) { if (loadFailed) throw IOException("Existing AutoGG settings could not be read; they were not overwritten.") val json = JsonObject() val gson = com.google.gson.GsonBuilder().setPrettyPrinting().create() json.add("default", gson.toJsonTree(defaults)) json.add("servers", gson.toJsonTree(servers)) + json.add("triggers", gson.toJsonTree(triggers)) Files.createDirectories(path.parent) val temporary = Files.createTempFile(path.parent, "autogg-", ".tmp") try { @@ -94,11 +118,23 @@ class AutoGgSettings(private val path: Path) { }) { "Each message must be 1-256 characters, without commands or control characters." } } - private fun normalizeServer(server: String): String { - val normalized = server.trim().lowercase().trimEnd('.') - require(normalized.length <= 253 && normalized.split('.').all { it.matches(Regex("[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?")) }) { - "Enter a server hostname without a port." - } - return normalized + private fun validateTriggers(triggers: List) { + require(triggers.all { value -> + value.isNotBlank() && value.length <= 1024 && value.none { it < ' ' || it == '\u007f' || it == '\u00a7' } + }) { "Each trigger must be 1-1024 characters, without control or formatting characters." } + } +} + +internal fun matchingOverride(address: String?, values: Map>): List? { + val host = address?.lowercase()?.substringBefore(':')?.trimEnd('.') ?: return null + return values.entries.filter { (server, _) -> host == server || host.endsWith(".$server") } + .maxByOrNull { it.key.length }?.value +} + +internal fun normalizeServer(server: String): String { + val normalized = server.trim().lowercase().trimEnd('.') + require(normalized.length <= 253 && normalized.split('.').all { it.matches(Regex("[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?")) }) { + "Enter a server hostname without a port." } + return normalized } diff --git a/src/main/kotlin/cc/modlabs/autogg/Autogg.kt b/src/main/kotlin/cc/modlabs/autogg/Autogg.kt index 917451c..dc9c919 100644 --- a/src/main/kotlin/cc/modlabs/autogg/Autogg.kt +++ b/src/main/kotlin/cc/modlabs/autogg/Autogg.kt @@ -57,9 +57,9 @@ class Autogg : ClientModInitializer { ClientSendMessageEvents.CHAT.register { message -> guard.sent(message) } ClientReceiveMessageEvents.GAME.register { message, _overlay -> - val event = detector.scanForEvent(message.string) ?: return@register + val server = client.getCurrentServer() + val event = detector.scanForEvent(message.string, server, settings.triggersFor(client.serverAddress)) ?: return@register if (event == cc.modlabs.autogg.chat.Event.END_GAME) { - val server = client.getCurrentServer() val messages = settings.messagesFor(client.serverAddress) val selected = guard.chooseMessage(server, messages) if (guard.shouldSend(server, selected ?: messages.first(), System.nanoTime() / 1_000_000) && selected != null) { @@ -70,9 +70,7 @@ class Autogg : ClientModInitializer { ClientPlayConnectionEvents.DISCONNECT.register(ClientPlayConnectionEvents.Disconnect { _, _ -> guard.reset() - if (client.getCurrentServer() == ServerConfig.MINEPLEX) { - EventDetector.mineplexStart = false - } + detector.reset() }) } } diff --git a/src/main/kotlin/cc/modlabs/autogg/MessageLists.kt b/src/main/kotlin/cc/modlabs/autogg/MessageLists.kt new file mode 100644 index 0000000..c27e509 --- /dev/null +++ b/src/main/kotlin/cc/modlabs/autogg/MessageLists.kt @@ -0,0 +1,40 @@ +package cc.modlabs.autogg + +internal fun formatMessages(messages: List): String = + messages.joinToString(" | ", transform = ::escapeItem) + +internal fun formatTriggers(triggers: List): String = + triggers.joinToString("|", transform = ::escapeItem) + +private fun escapeItem(value: String): String = value.replace("\\", "\\\\").replace("|", "\\|") + +internal fun parseMessages(text: String): List = parseItems(text, trim = true) + +internal fun parseTriggers(text: String): List { + if (text.isEmpty()) return emptyList() + return parseItems(text, trim = false).also { values -> + require(values.none(String::isBlank)) { "Triggers cannot contain empty entries. Clear the entire list to disable detection." } + } +} + +private fun parseItems(text: String, trim: Boolean): List { + require(text.isNotBlank()) { "Enter at least one message or trigger." } + val result = mutableListOf() + val item = StringBuilder() + var escaping = false + fun finish() { + result.add(if (trim) item.toString().trim() else item.toString()) + item.clear() + } + for (char in text) { + when { + escaping -> { require(char == '\\' || char == '|') { "Only pipes and backslashes can be escaped." }; item.append(char); escaping = false } + char == '\\' -> escaping = true + char == '|' -> finish() + else -> item.append(char) + } + } + require(!escaping) { "Incomplete escape at the end of the list." } + finish() + return result +} diff --git a/src/main/kotlin/cc/modlabs/autogg/chat/EventDetector.kt b/src/main/kotlin/cc/modlabs/autogg/chat/EventDetector.kt index 15ee80c..d914d2c 100644 --- a/src/main/kotlin/cc/modlabs/autogg/chat/EventDetector.kt +++ b/src/main/kotlin/cc/modlabs/autogg/chat/EventDetector.kt @@ -1,6 +1,5 @@ package cc.modlabs.autogg.chat -import cc.modlabs.autogg.Autogg import cc.modlabs.autogg.ServerConfig class EventDetector { @@ -69,8 +68,14 @@ class EventDetector { serverMessageEvents[ServerConfig.MINEPLEX] = mineplex } - fun scanForEvent(message: String): Event? { - val serverConfig: ServerConfig = Autogg.INSTANCE.client.getCurrentServer() ?: return null + fun builtInTriggers(server: ServerConfig?): List = + serverMessageEvents[server]?.keys?.toList() ?: emptyList() + + fun scanForEvent(message: String, server: ServerConfig?, customTriggers: List? = null): Event? { + if (customTriggers != null) { + return if (customTriggers.any(message::contains)) Event.END_GAME else null + } + val serverConfig = server ?: return null if (serverConfig === ServerConfig.MINEPLEX) { if (message.contains("You have been sent from ") && message.contains(" to Lobby")) { @@ -98,7 +103,9 @@ class EventDetector { return null } - companion object { - var mineplexStart: Boolean = false + private var mineplexStart = false + + fun reset() { + mineplexStart = false } } \ No newline at end of file diff --git a/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt b/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt new file mode 100644 index 0000000..2561981 --- /dev/null +++ b/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt @@ -0,0 +1,128 @@ +package cc.modlabs.autogg + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AutoGgEditorTest { + private fun settings() = AutoGgSettings(Files.createTempDirectory("autogg-editor").resolve("autogg.json")) + + @Test + fun `editor saves both fields and reloads the server settings`() { + val settings = settings() + val editor = AutoGgConfigEditor(settings) + editor.load("example.net") + editor.toggleMessages() + editor.messages = "gg | well played" + editor.toggleTriggers() + editor.triggers = "Fixture end|Fixture winner" + editor.save("EXAMPLE.NET.") + val loaded = AutoGgConfigEditor(settings) + loaded.load("example.net") + assertTrue(loaded.customMessages) + assertTrue(loaded.customTriggers) + assertEquals(listOf("gg", "well played"), settings.messagesFor("example.net")) + assertEquals(listOf("Fixture end", "Fixture winner"), settings.triggersFor("example.net")) + } + + @Test + fun `resetting detection preserves messages and reveals parent rules`() { + val settings = settings() + settings.saveServerConfig("example.net", listOf("parent"), listOf("Parent marker")) + settings.saveServerConfig("play.example.net", listOf("child"), listOf("Child marker")) + val editor = AutoGgConfigEditor(settings) + editor.load("play.example.net") + editor.toggleTriggers() + assertFalse(editor.customTriggers) + assertEquals("Parent marker", editor.triggers) + editor.save("play.example.net") + assertEquals(listOf("child"), settings.messagesFor("play.example.net")) + assertEquals(listOf("Parent marker"), settings.triggersFor("play.example.net")) + } + + @Test + fun `unknown hosts stay inactive until custom triggers are explicitly chosen`() { + val settings = settings() + val editor = AutoGgConfigEditor(settings) + editor.load("example.net") + assertFalse(editor.customTriggers) + assertEquals("", editor.triggers) + editor.save("example.net") + assertNull(settings.triggersFor("example.net")) + editor.toggleTriggers() + editor.save("example.net") + assertEquals(emptyList(), settings.triggersFor("example.net")) + editor.toggleTriggers() + editor.save("example.net") + assertNull(settings.triggersFor("example.net")) + } + + @Test + fun `editor previews builtin rules without saving them as overrides`() { + val settings = settings() + val editor = AutoGgConfigEditor(settings) + editor.load("mc.hypixel.net") + assertTrue(parseTriggers(editor.triggers).contains("Winner:")) + assertTrue(parseTriggers(editor.triggers).contains(" won the game!")) + editor.save("mc.hypixel.net") + assertNull(settings.triggersFor("mc.hypixel.net")) + } + + @Test + fun `default editing and field resets preserve unrelated server settings`() { + val settings = settings() + settings.saveServerConfig("example.net", listOf("custom"), listOf("Fixture end")) + val editor = AutoGgConfigEditor(settings) + editor.messages = "new default" + editor.save("") + assertEquals("new default", settings.message) + assertEquals(listOf("Fixture end"), settings.triggersFor("example.net")) + assertFailsWith { editor.toggleTriggers() } + editor.load("example.net") + editor.toggleMessages() + editor.save("example.net") + assertEquals(listOf("new default"), settings.messagesFor("example.net")) + assertEquals(listOf("Fixture end"), settings.triggersFor("example.net")) + } + + @Test + fun `unsaved fields cannot be applied to a different or invalid hostname`() { + val settings = settings() + val editor = AutoGgConfigEditor(settings) + editor.load("example.net") + editor.toggleTriggers() + editor.triggers = "Fixture end" + assertFailsWith { editor.save("another.net") } + assertNull(settings.triggersFor("another.net")) + assertFailsWith { editor.load("invalid:25565") } + assertEquals("example.net", editor.server) + assertEquals("Fixture end", editor.triggers) + } + + @Test + fun `invalid trigger editor input cannot partially save valid messages`() { + val settings = settings() + settings.saveServerConfig("example.net", listOf("original"), listOf("Original marker")) + val editor = AutoGgConfigEditor(settings) + editor.load("example.net") + editor.messages = "changed" + editor.triggers = "valid||invalid" + assertFailsWith { editor.save("example.net") } + assertEquals(listOf("original"), settings.messagesFor("example.net")) + assertEquals(listOf("Original marker"), settings.triggersFor("example.net")) + } + + @Test + fun `trigger editor preserves literal whitespace pipes and backslashes`() { + val values = listOf(" Winner: ", "fixture | winner", "fixture \\ result") + assertEquals(values, parseTriggers(formatTriggers(values))) + assertEquals(listOf("A", " B "), parseTriggers("A| B ")) + assertEquals(emptyList(), parseTriggers("")) + assertFailsWith { parseTriggers("A||B") } + assertFailsWith { parseTriggers("A\\") } + } +} diff --git a/src/test/kotlin/cc/modlabs/autogg/AutoGgTest.kt b/src/test/kotlin/cc/modlabs/autogg/AutoGgTest.kt index 8b76e97..fbb6476 100644 --- a/src/test/kotlin/cc/modlabs/autogg/AutoGgTest.kt +++ b/src/test/kotlin/cc/modlabs/autogg/AutoGgTest.kt @@ -2,6 +2,7 @@ package cc.modlabs.autogg import java.nio.file.Files import java.io.IOException +import com.google.gson.JsonParser import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -100,4 +101,15 @@ class AutoGgTest { assertFailsWith { settings.save("nice") } assertEquals("{ broken", Files.readString(path)) } + @Test + fun `message changes preserve stored trigger configuration`() { + val path = Files.createTempDirectory("autogg-triggers").resolve("autogg.json") + Files.writeString(path, """{"default":["gg"],"servers":{"example.net":["well played"]},"triggers":{"example.net":["Fixture end marker"]}}""") + val settings = AutoGgSettings(path) + settings.save("new default") + val saved = JsonParser.parseString(Files.readString(path)).asJsonObject + assertEquals("Fixture end marker", saved.getAsJsonObject("triggers")?.getAsJsonArray("example.net")?.get(0)?.asString) + assertEquals(listOf("well played"), AutoGgSettings(path).messagesFor("example.net")) + } + } diff --git a/src/test/kotlin/cc/modlabs/autogg/EventDetectorTest.kt b/src/test/kotlin/cc/modlabs/autogg/EventDetectorTest.kt new file mode 100644 index 0000000..18a5620 --- /dev/null +++ b/src/test/kotlin/cc/modlabs/autogg/EventDetectorTest.kt @@ -0,0 +1,74 @@ +package cc.modlabs.autogg + +import cc.modlabs.autogg.chat.Event +import cc.modlabs.autogg.chat.EventDetector +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EventDetectorTest { + @Test + fun `unknown servers detect only configured literal case-sensitive snippets`() { + val detector = EventDetector() + assertNull(detector.scanForEvent("Fixture end", null)) + assertEquals(Event.END_GAME, detector.scanForEvent("[Result] Fixture end!", null, listOf("Fixture end"))) + assertNull(detector.scanForEvent("fixture end", null, listOf("Fixture end"))) + assertNull(detector.scanForEvent("Fixture end", null, listOf("Fixture.*"))) + } + + @Test + fun `custom lists replace built-ins and empty lists disable detection`() { + val detector = EventDetector() + assertEquals(Event.END_GAME, detector.scanForEvent("Winner: Alex", ServerConfig.HYPIXEL)) + assertNull(detector.scanForEvent("Winner: Alex", ServerConfig.HYPIXEL, listOf("Fixture end"))) + assertEquals(Event.END_GAME, detector.scanForEvent("Fixture end", ServerConfig.HYPIXEL, listOf("Fixture end"))) + assertNull(detector.scanForEvent("Winner: Alex", ServerConfig.HYPIXEL, emptyList())) + } + + @Test + fun `existing server markers retain their behavior`() { + val detector = EventDetector() + for ((server, message) in listOf( + ServerConfig.HYPIXEL to "Your Overall Winstreak: 1", + ServerConfig.BEDWARS_PRACTICE to "Game Lost!", + ServerConfig.PVPLAND to "The match has ended!", + ServerConfig.MINEMEN to "Match Results" + )) { + assertEquals(Event.END_GAME, detector.scanForEvent(message, server)) + assertNull(detector.scanForEvent("ordinary chat", server)) + } + assertTrue(detector.builtInTriggers(ServerConfig.HYPIXEL).contains("Winner:")) + assertEquals(emptyList(), detector.builtInTriggers(null)) + } + + @Test + fun `mineplex state machine remains isolated from custom triggers and resets`() { + val detector = EventDetector() + val end = "Chat> Chat is no longer silenced." + val server = ServerConfig.MINEPLEX + assertEquals(Event.END_GAME, detector.scanForEvent("Fixture end", server, listOf("Fixture end"))) + assertEquals(Event.START_GAME, detector.scanForEvent(end, server)) + assertEquals(Event.END_GAME, detector.scanForEvent(end, server)) + assertNull(detector.scanForEvent("1st Place - Alex", server)) + assertEquals(Event.END_GAME, detector.scanForEvent(end, server)) + detector.scanForEvent("1st Place - Alex", server) + detector.scanForEvent("You have been sent from Arena to Lobby", server) + assertEquals(Event.START_GAME, detector.scanForEvent(end, server)) + detector.reset() + assertEquals(Event.START_GAME, detector.scanForEvent(end, server)) + } + + @Test + fun `custom matches retain burst and hypixel duplicate protection`() { + val detector = EventDetector() + val guard = AutoGgGuard() + assertEquals(Event.END_GAME, detector.scanForEvent("Fixture end", null, listOf("Fixture end"))) + assertTrue(guard.shouldSend(null, "gg", 1000)) + guard.sent("gg") + assertFalse(guard.shouldSend(null, "gg", 2000)) + assertTrue(guard.shouldSend(null, "gg", 6000)) + assertNull(guard.chooseMessage(ServerConfig.HYPIXEL, listOf("gg"))) + } +} diff --git a/src/test/kotlin/cc/modlabs/autogg/TriggerSettingsTest.kt b/src/test/kotlin/cc/modlabs/autogg/TriggerSettingsTest.kt new file mode 100644 index 0000000..88c4a85 --- /dev/null +++ b/src/test/kotlin/cc/modlabs/autogg/TriggerSettingsTest.kt @@ -0,0 +1,88 @@ +package cc.modlabs.autogg + +import java.io.IOException +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class TriggerSettingsTest { + @Test + fun `trigger overrides survive reload independently from messages`() { + val path = Files.createTempDirectory("autogg-triggers").resolve("autogg.json") + val settings = AutoGgSettings(path) + settings.saveServerConfig("EXAMPLE.NET.", listOf("well played"), listOf("Fixture end")) + settings.saveServer("play.example.net", listOf("nice game")) + val loaded = AutoGgSettings(path) + assertEquals(listOf("Fixture end"), loaded.triggersFor("PLAY.EXAMPLE.NET:25565")) + assertEquals(listOf("nice game"), loaded.messagesFor("play.example.net")) + assertNull(loaded.triggersFor("notexample.net")) + assertNull(loaded.triggersFor("example.net.other.org")) + assertNull(loaded.triggersFor(null)) + } + + @Test + fun `empty custom triggers disable while reset restores inherited rules`() { + val path = Files.createTempDirectory("autogg-triggers").resolve("autogg.json") + val settings = AutoGgSettings(path) + settings.saveServerConfig("example.net", listOf("gg"), listOf("Fixture end")) + settings.saveServerConfig("play.example.net", listOf("nice"), emptyList()) + assertEquals(emptyList(), AutoGgSettings(path).triggersFor("play.example.net")) + settings.saveServerConfig("play.example.net", listOf("nice"), null) + val loaded = AutoGgSettings(path) + assertEquals(listOf("Fixture end"), loaded.triggersFor("play.example.net")) + assertEquals(listOf("nice"), loaded.messagesFor("play.example.net")) + } + + @Test + fun `existing message schema and legacy import accept trigger saves`() { + val dir = Files.createTempDirectory("autogg-legacy") + val path = dir.resolve("autogg.json") + Files.writeString(path, """{"default":["default gg"],"servers":{"example.net":["nice"]}}""") + val old = AutoGgSettings(path) + assertNull(old.triggersFor("example.net")) + old.saveServerConfig("another.net", null, listOf("Fixture end")) + assertEquals(listOf("nice"), AutoGgSettings(path).messagesFor("example.net")) + val legacyPath = dir.resolve("legacy.json") + Files.writeString(dir.resolve("autogg-message.txt"), "legacy gg") + AutoGgSettings(legacyPath).saveServerConfig("example.net", null, listOf("Fixture end")) + assertEquals("legacy gg", AutoGgSettings(legacyPath).message) + } + + @Test + fun `invalid server saves do not partially replace either field`() { + val path = Files.createTempDirectory("autogg-validation").resolve("autogg.json") + val settings = AutoGgSettings(path) + settings.saveServerConfig("example.net", listOf("original"), listOf("Original marker")) + val before = Files.readString(path) + for (invalid in listOf("", " ", "a\nb", "a\u007fb", "§a", "a".repeat(1025))) { + assertFailsWith { + settings.saveServerConfig("example.net", listOf("changed"), listOf(invalid)) + } + assertEquals(before, Files.readString(path)) + assertEquals(listOf("original"), settings.messagesFor("example.net")) + assertEquals(listOf("Original marker"), settings.triggersFor("example.net")) + } + assertFailsWith { + settings.saveServerConfig("example.net", listOf("/command"), listOf("Changed marker")) + } + assertEquals(before, Files.readString(path)) + } + + @Test + fun `trigger whitespace and received command text remain literal`() { + val path = Files.createTempDirectory("autogg-literal").resolve("autogg.json") + AutoGgSettings(path).saveServerConfig("example.net", null, listOf(" Winner: ", "/match finished")) + assertEquals(listOf(" Winner: ", "/match finished"), AutoGgSettings(path).triggersFor("example.net")) + } + + @Test + fun `malformed stored trigger data cannot be overwritten`() { + val path = Files.createTempDirectory("autogg-corrupt").resolve("autogg.json") + val bad = """{"default":["gg"],"servers":{},"triggers":{"example.net":[""]}}""" + Files.writeString(path, bad) + assertFailsWith { AutoGgSettings(path).saveServerConfig("example.net", listOf("changed"), listOf("Fixture")) } + assertEquals(bad, Files.readString(path)) + } +} From 3d4ab85f43d54b8c0a82d2d57d63d12ae6473bce Mon Sep 17 00:00:00 2001 From: Liam Sage Date: Wed, 4 Nov 2026 12:27:27 +0100 Subject: [PATCH 2/2] fix: preserve editor drafts and long trigger lists --- README.md | 4 +- .../cc/modlabs/autogg/AutoGgConfigEditor.kt | 21 ++++++---- .../kotlin/cc/modlabs/autogg/AutoGgModMenu.kt | 3 +- .../cc/modlabs/autogg/ConfigValueField.kt | 8 ++++ .../cc/modlabs/autogg/AutoGgEditorTest.kt | 40 +++++++++++++++++++ .../cc/modlabs/autogg/ConfigValueFieldTest.kt | 29 ++++++++++++++ 6 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/cc/modlabs/autogg/ConfigValueField.kt create mode 100644 src/test/kotlin/cc/modlabs/autogg/ConfigValueFieldTest.kt diff --git a/README.md b/README.md index 73ffa1f..5952ce0 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,14 @@ Elevate your gameplay and foster a friendly community with Auto GG! ## Per-server settings -Open Auto GG's settings in Mod Menu. Enter a hostname, such as `hypixel.net`, and select **Load** before editing it. A blank hostname edits the default outgoing messages. Switch between **Messages** and **End triggers**, choose **Customize**, edit the list, then **Save**. **Done** closes the screen without saving unsaved edits. Both tabs are saved together. +Open Auto GG's settings in Mod Menu. Enter a hostname, such as `hypixel.net`, and select **Load** before editing it. A blank hostname edits the default outgoing messages. Switch between **Messages** and **End triggers**, choose **Customize**, edit the list, then **Save**. **Done** closes the screen without saving unsaved edits. Both tabs are saved together. Unsaved drafts are kept when switching hostnames within the screen. Messages and triggers inherit independently from the most specific matching hostname, then fall back to default messages and built-in detection. For example, a message override for `play.example.net` can still use triggers configured for `example.net`. Hostname matching ignores case and the connection's port. Enter saved hostnames without a port. - **Messages:** separate alternatives with ` | `. One is chosen at random when a game ends. Messages are trimmed and must be 1–256 characters, without commands, control characters or formatting codes. - **End triggers:** separate literal snippets with `|`. Matching is case-sensitive, and **spaces are significant**, including spaces next to the separator. A match anywhere in a received game/system message ends the game. Each snippet must be 1–1024 characters, without control characters or formatting codes. These are plain text, not regular expressions. - In either editor, use `\|` for a literal pipe and `\\` for a literal backslash. -- Custom triggers **replace** that hostname's built-in detection. A completely empty custom trigger list explicitly disables detection. **Reset to inherited detection** removes the custom override after you select **Save**; this may reveal a parent-domain override or the server's built-in detector. Resetting messages does not reset triggers, and vice versa. +- Custom triggers **replace** that hostname's built-in detection, including Mineplex's special start/end sequencing. A completely empty custom trigger list explicitly disables detection. **Reset to inherited detection** removes the custom override after you select **Save**; this may reveal a parent-domain override or the server's built-in detector. Resetting messages does not reset triggers, and vice versa. - Unsupported servers, including mcpvp.club and PvP Legacy, remain inactive until you enter reliable end-message snippets from that server. No guessed presets are supplied. Capture the actual system/game chat text; title-only or scoreboard-only results are not detected. The built-in servers remain Hypixel, BedwarsPractice, PvP Land, Minemen, and Mineplex. Their existing behavior is unchanged unless custom triggers apply. Repeated end messages within three seconds are suppressed. Hypixel additionally avoids repeating the last sent chat message when another configured message is available, or skips sending until another chat message is sent. diff --git a/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt b/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt index 65e20c1..540921b 100644 --- a/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt +++ b/src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt @@ -5,6 +5,8 @@ import cc.modlabs.autogg.chat.EventDetector /** Unsaved editor state, separate from Minecraft widgets so scope and save behavior can be tested. */ internal class AutoGgConfigEditor(private val settings: AutoGgSettings) { private val detector = EventDetector() + private data class Draft(val messages: String, val triggers: String, val customMessages: Boolean, val customTriggers: Boolean) + private val drafts = mutableMapOf() var server = "" private set var messages = formatMessages(settings.defaultMessages) @@ -17,19 +19,23 @@ internal class AutoGgConfigEditor(private val settings: AutoGgSettings) { fun load(hostname: String) { val name = key(hostname) + drafts[server] = Draft(messages, triggers, customMessages, customTriggers) server = name - customMessages = isDefault || settings.overrides.containsKey(name) - customTriggers = !isDefault && settings.triggerOverrides.containsKey(name) - messages = formatMessages(settings.messagesFor(name)) - triggers = if (isDefault) "" else formatTriggers(settings.triggersFor(name) ?: builtInTriggers()) + val draft = drafts[name] + customMessages = draft?.customMessages ?: (isDefault || settings.overrides.containsKey(name)) + customTriggers = draft?.customTriggers ?: (!isDefault && settings.triggerOverrides.containsKey(name)) + messages = if (customMessages) draft?.messages ?: formatMessages(settings.messagesFor(name)) else inheritedMessages() + triggers = when { + isDefault -> "" + customTriggers -> draft?.triggers ?: formatTriggers(settings.triggersFor(name) ?: emptyList()) + else -> formatTriggers(inheritedTriggers() ?: builtInTriggers()) + } } fun toggleMessages() { require(!isDefault) { "Default messages cannot inherit from another setting." } customMessages = !customMessages - if (!customMessages) messages = formatMessages( - matchingOverride(server, settings.overrides - server) ?: settings.defaultMessages - ) + if (!customMessages) messages = inheritedMessages() } fun toggleTriggers() { @@ -58,6 +64,7 @@ internal class AutoGgConfigEditor(private val settings: AutoGgSettings) { else -> "No detection configured. Customize and enter real server end text." } + private fun inheritedMessages(): String = formatMessages(matchingOverride(server, settings.overrides - server) ?: settings.defaultMessages) private fun inheritedTriggers(): List? = matchingOverride(server, settings.triggerOverrides - server) private fun builtInTriggers(): List = detector.builtInTriggers(ServerConfig.entries.firstOrNull { it.matches(server) }) private fun key(hostname: String): String = if (hostname.isBlank()) "" else normalizeServer(hostname) diff --git a/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt b/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt index 4e7fc3b..846845c 100644 --- a/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt +++ b/src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt @@ -54,8 +54,7 @@ private class AutoGgConfigScreen(private val parent: Screen, settings: AutoGgSet status = editor.detectionDescription() }.bounds(x + 153, 68, 147, 20).build()) - values = addRenderableWidget(EditBox(font, x, 106, 300, 20, Component.literal("Configured values, separated by pipes"))) - values.setMaxLength(8192) + values = addRenderableWidget(configureConfigValueField(EditBox(font, x, 106, 300, 20, Component.literal("Configured values, separated by pipes")))) values.setResponder { value -> if (!refreshing) { if (showTriggers) editor.triggers = value else editor.messages = value diff --git a/src/main/kotlin/cc/modlabs/autogg/ConfigValueField.kt b/src/main/kotlin/cc/modlabs/autogg/ConfigValueField.kt new file mode 100644 index 0000000..7015cca --- /dev/null +++ b/src/main/kotlin/cc/modlabs/autogg/ConfigValueField.kt @@ -0,0 +1,8 @@ +package cc.modlabs.autogg + +import net.minecraft.client.gui.components.EditBox + +internal fun configureConfigValueField(field: EditBox): EditBox = field.apply { + // Settings limit each item, not the whole list. Never truncate an existing valid list. + setMaxLength(Int.MAX_VALUE) +} diff --git a/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt b/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt index 2561981..2720bf5 100644 --- a/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt +++ b/src/test/kotlin/cc/modlabs/autogg/AutoGgEditorTest.kt @@ -125,4 +125,44 @@ class AutoGgEditorTest { assertFailsWith { parseTriggers("A||B") } assertFailsWith { parseTriggers("A\\") } } + @Test + fun `switching hostnames preserves each unsaved draft and mode`() { + val settings = settings() + val editor = AutoGgConfigEditor(settings) + editor.messages = "draft default" + editor.load("example.net") + editor.toggleMessages() + editor.toggleTriggers() + editor.messages = "draft message" + editor.triggers = "Draft marker" + editor.load("another.net") + editor.toggleTriggers() + editor.triggers = "Another draft" + editor.load("EXAMPLE.NET.") + assertTrue(editor.customMessages) + assertTrue(editor.customTriggers) + assertEquals("draft message", editor.messages) + assertEquals("Draft marker", editor.triggers) + editor.load("another.net") + assertEquals("Another draft", editor.triggers) + editor.load("") + assertEquals("draft default", editor.messages) + assertEquals("gg", settings.message) + assertNull(settings.triggersFor("example.net")) + } + + @Test + fun `returning to inherited drafts refreshes the parent preview`() { + val settings = settings() + settings.saveServerConfig("example.net", null, listOf("Original")) + val editor = AutoGgConfigEditor(settings) + editor.load("play.example.net") + editor.load("example.net") + editor.triggers = "Updated" + editor.save("example.net") + editor.load("play.example.net") + assertFalse(editor.customTriggers) + assertEquals("Updated", editor.triggers) + } + } diff --git a/src/test/kotlin/cc/modlabs/autogg/ConfigValueFieldTest.kt b/src/test/kotlin/cc/modlabs/autogg/ConfigValueFieldTest.kt new file mode 100644 index 0000000..11ac99f --- /dev/null +++ b/src/test/kotlin/cc/modlabs/autogg/ConfigValueFieldTest.kt @@ -0,0 +1,29 @@ +package cc.modlabs.autogg + +import java.nio.file.Files +import net.minecraft.client.gui.Font +import net.minecraft.client.gui.components.EditBox +import net.minecraft.network.chat.Component +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConfigValueFieldTest { + @Test + fun `editing a long saved list does not truncate any trigger`() { + val path = Files.createTempDirectory("autogg-large-editor").resolve("autogg.json") + val original = List(10) { "trigger-$it-" + "x".repeat(990) } + val settings = AutoGgSettings(path) + settings.saveServerConfig("example.net", null, original) + val editor = AutoGgConfigEditor(settings) + editor.load("example.net") + // A null font keeps this real Minecraft widget headless; no rendering is required. + val constructor = EditBox::class.java.getConstructor(Font::class.java, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Component::class.java) + val field = configureConfigValueField(constructor.newInstance(null, 300, 20, Component.literal("Test values"))) + field.value = editor.triggers + assertEquals(editor.triggers, field.value) + field.setResponder { editor.triggers = it } + field.value = "T" + field.value.drop(1) + editor.save("example.net") + assertEquals(listOf("T" + original.first().drop(1)) + original.drop(1), AutoGgSettings(path).triggersFor("example.net")) + } +}