diff --git a/src/main/kotlin/gg/grounds/gui/AnvilInput.kt b/src/main/kotlin/gg/grounds/gui/AnvilInput.kt index 30c51bc..cef7614 100644 --- a/src/main/kotlin/gg/grounds/gui/AnvilInput.kt +++ b/src/main/kotlin/gg/grounds/gui/AnvilInput.kt @@ -1,5 +1,6 @@ package gg.grounds.gui +import gg.grounds.gui.bedrock.BedrockForms import net.kyori.adventure.text.Component import net.minestom.server.entity.Player import net.minestom.server.event.EventNode @@ -19,7 +20,7 @@ import net.minestom.server.item.Material */ class AnvilInput( player: Player, - title: Component, + private val title: Component, initial: String = "", private val confirmLabel: Component = Component.text("Confirm"), private val onConfirm: (String) -> Unit, @@ -47,6 +48,34 @@ class AnvilInput( setButton(2, confirm) } + /** + * On Bedrock this is a native text-input form instead of an anvil. + * + * That is the one place where Bedrock is served better than Java: the anvil path relies on the + * client sending its rename field on roughly every keystroke, and only once slot 0 holds an + * item. A form input has neither constraint. See [BedrockForms] — and call + * [BedrockForms.install] once at startup, or the prompt never answers. + */ + override fun open() { + if (!BedrockForms.isBedrock(player)) { + super.open() + return + } + BedrockForms.textInput( + player = player, + title = title, + label = confirmLabel, + initial = input, + ) { answer -> + // Dismissing the form is the same as closing the anvil without confirming: nothing + // happens. Only a real answer calls back, which is what the Java path does too. + if (answer != null) { + input = answer + onConfirm(answer) + } + } + } + override fun configureNode(node: EventNode) { super.configureNode(node) node.addListener(PlayerAnvilInputEvent::class.java) { event -> diff --git a/src/main/kotlin/gg/grounds/gui/Confirm.kt b/src/main/kotlin/gg/grounds/gui/Confirm.kt index ee27998..0bfc90d 100644 --- a/src/main/kotlin/gg/grounds/gui/Confirm.kt +++ b/src/main/kotlin/gg/grounds/gui/Confirm.kt @@ -1,25 +1,56 @@ package gg.grounds.gui +import gg.grounds.gui.bedrock.BedrockForms import net.kyori.adventure.text.Component +import net.minestom.server.component.DataComponents import net.minestom.server.entity.Player +import net.minestom.server.inventory.Inventory +import net.minestom.server.inventory.InventoryType +import net.minestom.server.item.ItemStack import net.minestom.server.item.Material /** * A yes/no dialog. [onCancel] also runs when the player closes the GUI without choosing (or * disconnects), so it fires exactly once for every shown dialog. Call [Gui.open] on the result. + * + * A Bedrock player gets a native modal form instead of the chest — same contract, including the + * "cancel also means dismissed" rule. See [BedrockForms] for why translating the chest is not an + * option, and call [BedrockForms.install] once at startup or the dialog never answers. */ fun confirmGui( player: Player, title: Component, - confirmItem: net.minestom.server.item.ItemStack = - item(Material.LIME_DYE) { name(Component.text("Confirm")) }, - cancelItem: net.minestom.server.item.ItemStack = - item(Material.RED_DYE) { name(Component.text("Cancel")) }, + confirmItem: ItemStack = item(Material.LIME_DYE) { name(Component.text("Confirm")) }, + cancelItem: ItemStack = item(Material.RED_DYE) { name(Component.text("Cancel")) }, onCancel: () -> Unit = {}, onConfirm: () -> Unit, -): Gui = - gui(player, title, rows = 3) { - var decided = false +): Gui { + var decided = false + + // A subclass rather than a second entry point: every existing call site keeps working, and the + // decision about which surface to draw belongs at open() time, not at the caller. + val dialog = + object : Gui(player, Inventory(InventoryType.CHEST_3_ROW, title)) { + override fun open() { + if (!BedrockForms.isBedrock(player)) { + super.open() + return + } + // The form's own close is the dismissal, so `decided` is not consulted here — + // BedrockForms answers exactly once per form, including on disconnect. + BedrockForms.modal( + player = player, + title = title, + content = Component.empty(), + button1 = confirmItem.label(fallback = "Confirm"), + button2 = cancelItem.label(fallback = "Cancel"), + ) { answer -> + if (answer == true) onConfirm() else onCancel() + } + } + } + + return dialog.apply { button(11, confirmItem) { onClick { decided = true @@ -36,3 +67,11 @@ fun confirmGui( } onClose { if (!decided) onCancel() } } +} + +/** + * The item's display name, for a surface that renders text rather than items. A form button is a + * label; an [ItemStack] is a texture that usually carries one. + */ +private fun ItemStack.label(fallback: String): Component = + get(DataComponents.ITEM_NAME) ?: Component.text(fallback) diff --git a/src/main/kotlin/gg/grounds/gui/bedrock/BedrockForms.kt b/src/main/kotlin/gg/grounds/gui/bedrock/BedrockForms.kt new file mode 100644 index 0000000..fe1c127 --- /dev/null +++ b/src/main/kotlin/gg/grounds/gui/bedrock/BedrockForms.kt @@ -0,0 +1,269 @@ +package gg.grounds.gui.bedrock + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import net.minestom.server.MinecraftServer +import net.minestom.server.entity.Player +import net.minestom.server.event.player.PlayerDisconnectEvent +import net.minestom.server.event.player.PlayerPluginMessageEvent + +/** + * Bedrock forms, over Floodgate's own plugin-message channel. + * + * A Bedrock player reaches the network through Geyser, so by the time this server sees them they + * are an ordinary Java-protocol player — which is exactly why a themed [gg.grounds.gui.Gui] fails + * for them. The theme draws its panels with private-use glyphs positioned by negative advance and + * composited by a Java core shader; the Bedrock client has neither. What arrives is a bare + * container with missing-glyph text where the interface should be. + * + * Forms are Bedrock's native answer, and they need no resource pack. + * + * **This server sends them itself; Floodgate is not on the classpath and does not need to be.** + * Floodgate's own backend-to-client path is this channel, and its proxy handler forwards a message + * from a *server* straight to the client (`Result.forward()`), then routes the response back to the + * connected server whenever the form id has the `0x8000` bit clear. That bit means "sent by a + * proxy", so a backend keeps it clear and owns its own id space. + * + * Wire format, from Floodgate's `FormChannel.createFormData`: + * ```text + * send byte 0 form type: 0 = form (simple), 1 = modal, 2 = custom_form + * byte 1..2 form id, big-endian short, high bit clear + * byte 3..N the form's JSON, UTF-8 + * + * response byte 0..1 form id, big-endian short + * byte 2..N response data, UTF-8 + * ``` + * + * Call [install] once at startup. Without it, forms are still sent but no response ever arrives, so + * every callback would be stranded — [install] is what makes a form a question rather than a + * broadcast. + */ +object BedrockForms { + + /** Floodgate's channel. Both directions travel on it. */ + const val CHANNEL: String = "floodgate:form" + + private const val TYPE_MODAL: Byte = 1 + private const val TYPE_CUSTOM: Byte = 2 + + /** + * Whether this player is on Bedrock. + * + * Floodgate derives a Bedrock player's UUID from their XUID and zeroes the high eight bytes, so + * this is true for Bedrock players and for no real Java UUID. It costs nothing and needs no + * Floodgate dependency, which matters here: a Minestom backend receives the UUID through modern + * forwarding and little else. + * + * A player who linked a Java account is a caveat worth knowing rather than one this can solve: + * neighbouring services may hold that account's id instead, so confirm which id a given service + * actually sees before branching on it there. + */ + fun isBedrock(player: Player): Boolean = player.uuid.mostSignificantBits == 0L + + private class Pending(val onResponse: (String?) -> Unit) + + private class State { + var nextId: Int = 0 + val open = ConcurrentHashMap() + } + + private val states = ConcurrentHashMap() + + @Volatile private var installed = false + + /** + * Registers the response listener. Idempotent, so calling it from several modules is harmless. + */ + @Synchronized + fun install() { + if (installed) return + installed = true + val node = MinecraftServer.getGlobalEventHandler() + node.addListener(PlayerPluginMessageEvent::class.java) { event -> + if (event.identifier == CHANNEL) handleResponse(event.player, event.message) + } + // A disconnect answers every form the player still had open. Without this a caller that + // waits for an answer waits forever, and the map leaks one entry per abandoned form. + node.addListener(PlayerDisconnectEvent::class.java) { event -> + states.remove(event.player.uuid)?.open?.values?.forEach { it.onResponse(null) } + } + } + + /** + * Shows a yes/no dialog. [onResponse] receives true, false, or null when the player dismissed + * it — so it fires exactly once for every form shown, which is the contract + * [gg.grounds.gui.confirmGui] already promises on Java. + */ + fun modal( + player: Player, + title: Component, + content: Component, + button1: Component, + button2: Component, + onResponse: (Boolean?) -> Unit, + ) { + val json = modalJson(plain(title), plain(content), plain(button1), plain(button2)) + send(player, TYPE_MODAL, json) { response -> + onResponse( + when (response?.trim()) { + "true" -> true + "false" -> false + else -> null + } + ) + } + } + + /** + * Shows a single text input. [onResponse] receives the text, or null when the player dismissed + * the form. + * + * This is the one place where Bedrock is better served than Java: the Java path types into an + * anvil rename field, which sends on roughly every keystroke and only works once the first slot + * holds an item. + */ + fun textInput( + player: Player, + title: Component, + label: Component, + placeholder: String = "", + initial: String = "", + onResponse: (String?) -> Unit, + ) { + val json = customInputJson(plain(title), plain(label), placeholder, initial) + send(player, TYPE_CUSTOM, json) { response -> onResponse(firstStringOfJsonArray(response)) } + } + + /** + * Cumulus's `modal` shape. Field names and the `type` value come from Cumulus's own codec and + * its `@SerializedName` annotations, not from guesswork — a wrong key here is a form that + * silently never appears. + */ + internal fun modalJson( + title: String, + content: String, + button1: String, + button2: String, + ): String = buildString { + append("{\"type\":\"modal\",\"title\":") + appendJsonString(title) + append(",\"content\":") + appendJsonString(content) + append(",\"button1\":") + appendJsonString(button1) + append(",\"button2\":") + appendJsonString(button2) + append('}') + } + + /** Cumulus's `custom_form` with a single `input` component. */ + internal fun customInputJson( + title: String, + label: String, + placeholder: String, + initial: String, + ): String = buildString { + append("{\"type\":\"custom_form\",\"title\":") + appendJsonString(title) + append(",\"content\":[{\"type\":\"input\",\"text\":") + appendJsonString(label) + append(",\"placeholder\":") + appendJsonString(placeholder) + append(",\"default\":") + appendJsonString(initial) + append("}]}") + } + + private fun send(player: Player, type: Byte, json: String, onResponse: (String?) -> Unit) { + val state = states.computeIfAbsent(player.uuid) { State() } + val id: Short + synchronized(state) { + // Short.MAX_VALUE keeps the 0x8000 bit clear, which is what tells the proxy this form + // came from a server and its response belongs back here. + id = state.nextId.toShort() + state.nextId = if (state.nextId == Short.MAX_VALUE.toInt()) 0 else state.nextId + 1 + } + state.open[id] = Pending(onResponse) + + val body = json.toByteArray(Charsets.UTF_8) + val data = ByteArray(body.size + 3) + data[0] = type + data[1] = (id.toInt() shr 8 and 0xFF).toByte() + data[2] = (id.toInt() and 0xFF).toByte() + body.copyInto(data, 3) + player.sendPluginMessage(CHANNEL, data) + } + + private fun handleResponse(player: Player, data: ByteArray) { + if (data.size < 2) return + val id = ((data[0].toInt() and 0xFF) shl 8 or (data[1].toInt() and 0xFF)).toShort() + val pending = states[player.uuid]?.open?.remove(id) ?: return + val body = String(data, 2, data.size - 2, Charsets.UTF_8) + // Geyser answers a dismissed form with an empty or null body; Floodgate's own codecs treat + // both as "no answer", and so does every caller here. + pending.onResponse(if (body.isBlank() || body.trim() == "null") null else body) + } + + private fun plain(component: Component): String = + PlainTextComponentSerializer.plainText().serialize(component) + + /** + * Pulls the first element out of a custom form's response array. + * + * A custom form answers with a JSON array, one entry per component. There is exactly one + * component here, so a full parser would be more machinery than the shape deserves — but the + * value is player-typed, so quotes and escapes inside it are ordinary rather than exceptional. + */ + internal fun firstStringOfJsonArray(response: String?): String? { + val trimmed = response?.trim() ?: return null + if (!trimmed.startsWith("[")) return null + val firstQuote = trimmed.indexOf('"') + if (firstQuote < 0) return null + val out = StringBuilder() + var i = firstQuote + 1 + while (i < trimmed.length) { + when (val c = trimmed[i]) { + '"' -> return out.toString() + '\\' -> { + if (i + 1 >= trimmed.length) return out.toString() + when (val escaped = trimmed[i + 1]) { + 'n' -> out.append('\n') + 'r' -> out.append('\r') + 't' -> out.append('\t') + 'b' -> out.append('\b') + 'f' -> out.append(' ') + 'u' -> { + if (i + 5 < trimmed.length) { + out.append(trimmed.substring(i + 2, i + 6).toInt(16).toChar()) + i += 4 + } + } + else -> out.append(escaped) + } + i++ + } + else -> out.append(c) + } + i++ + } + return out.toString() + } + + private fun StringBuilder.appendJsonString(value: String) { + append('"') + for (c in value) { + when { + c == '"' -> append("\\\"") + c == '\\' -> append("\\\\") + c == '\n' -> append("\\n") + c == '\r' -> append("\\r") + c == '\t' -> append("\\t") + c < ' ' -> append("\\u%04x".format(c.code)) + else -> append(c) + } + } + append('"') + } +} diff --git a/src/test/kotlin/gg/grounds/gui/bedrock/BedrockFormsTest.kt b/src/test/kotlin/gg/grounds/gui/bedrock/BedrockFormsTest.kt new file mode 100644 index 0000000..2f23440 --- /dev/null +++ b/src/test/kotlin/gg/grounds/gui/bedrock/BedrockFormsTest.kt @@ -0,0 +1,78 @@ +package gg.grounds.gui.bedrock + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * These pin the wire format against Cumulus's own codecs. Every key and every `type` value below + * was read out of `ModalFormCodec`, `CustomFormCodec`, `FormType` and `ComponentType` in + * GeyserMC/Cumulus — a rename there is a form that silently never appears, which is exactly the + * failure a test should catch rather than a player. + */ +class BedrockFormsTest { + + @Test + fun `modal json matches cumulus field names`() { + assertEquals( + """{"type":"modal","title":"Delete map?","content":"This cannot be undone.",""" + + """"button1":"Delete","button2":"Keep"}""", + BedrockForms.modalJson("Delete map?", "This cannot be undone.", "Delete", "Keep"), + ) + } + + @Test + fun `custom form json carries a single input component`() { + assertEquals( + """{"type":"custom_form","title":"Rename","content":[{"type":"input",""" + + """"text":"New name","placeholder":"e.g. crater","default":"crater"}]}""", + BedrockForms.customInputJson("Rename", "New name", "e.g. crater", "crater"), + ) + } + + @Test + fun `player text is escaped rather than breaking the payload`() { + // A player types the quote; without escaping this truncates the JSON and the form is + // dropped by the client with nothing in any log. + val json = BedrockForms.customInputJson("t", "l", "p", """say "hi"\ok""") + assertEquals( + """{"type":"custom_form","title":"t","content":[{"type":"input",""" + + """"text":"l","placeholder":"p","default":"say \"hi\"\\ok"}]}""", + json, + ) + } + + @Test + fun `newlines and control characters are escaped`() { + assertEquals( + """{"type":"modal","title":"a\nb","content":"c\td","button1":"e","button2":"f"}""", + BedrockForms.modalJson("a\nb", "c\td", "e", "f"), + ) + } + + @Test + fun `custom form response yields the typed text`() { + assertEquals("crater", BedrockForms.firstStringOfJsonArray("""["crater"]""")) + } + + @Test + fun `custom form response unescapes what the player typed`() { + assertEquals("""say "hi"""", BedrockForms.firstStringOfJsonArray("""["say \"hi\""]""")) + assertEquals("a\nb", BedrockForms.firstStringOfJsonArray("""["a\nb"]""")) + assertEquals("é", BedrockForms.firstStringOfJsonArray("""["é"]""")) + } + + @Test + fun `a dismissed form reads as no answer`() { + // Geyser answers a dismissed form with an empty or null body, and every caller here treats + // that as "the player said nothing" rather than as the empty string. + assertNull(BedrockForms.firstStringOfJsonArray(null)) + assertNull(BedrockForms.firstStringOfJsonArray("")) + assertNull(BedrockForms.firstStringOfJsonArray("null")) + } + + @Test + fun `an empty text answer is still an answer`() { + assertEquals("", BedrockForms.firstStringOfJsonArray("""[""]""")) + } +}