Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <message>` 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. Unsaved drafts are kept when switching hostnames within the screen.

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, 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.

Use `/autogg <message>` 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

Expand Down
71 changes: 71 additions & 0 deletions src/main/kotlin/cc/modlabs/autogg/AutoGgConfigEditor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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()
private data class Draft(val messages: String, val triggers: String, val customMessages: Boolean, val customTriggers: Boolean)
private val drafts = mutableMapOf<String, Draft>()
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)
drafts[server] = Draft(messages, triggers, customMessages, customTriggers)
server = name
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 = inheritedMessages()
}

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 inheritedMessages(): String = formatMessages(matchingOverride(server, settings.overrides - server) ?: settings.defaultMessages)
private fun inheritedTriggers(): List<String>? = matchingOverride(server, settings.triggerOverrides - server)
private fun builtInTriggers(): List<String> = detector.builtInTriggers(ServerConfig.entries.firstOrNull { it.matches(server) })
private fun key(hostname: String): String = if (hostname.isBlank()) "" else normalizeServer(hostname)
}
142 changes: 78 additions & 64 deletions src/main/kotlin/cc/modlabs/autogg/AutoGgModMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,94 +15,108 @@ 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(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
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>): String =
messages.joinToString(" | ") { it.replace("\\", "\\\\").replace("|", "\\|") }

internal fun parseMessages(text: String): List<String> {
require(text.isNotBlank()) { "Enter at least one message." }
val result = mutableListOf<String>()
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
}
Loading
Loading