From 54d8d057296708258c4e58cedfbd3808b15ed5d1 Mon Sep 17 00:00:00 2001 From: pothemagicdragon Date: Wed, 8 Jul 2026 21:20:29 -0700 Subject: [PATCH 1/5] removes time zone from time --- src/main/kotlin/com/lambda/util/CommunicationUtils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/lambda/util/CommunicationUtils.kt b/src/main/kotlin/com/lambda/util/CommunicationUtils.kt index 596c88943..144fca7a4 100644 --- a/src/main/kotlin/com/lambda/util/CommunicationUtils.kt +++ b/src/main/kotlin/com/lambda/util/CommunicationUtils.kt @@ -61,7 +61,7 @@ object CommunicationUtils { fun currentTime(): String = LocalDateTime.now() .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)) + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)) fun Any.debug(message: String, source: String = "") = log(LogLevel.Debug.text(message), LogLevel.Debug, source) fun Any.debug(message: Text, source: Text = Text.empty()) = log(message, LogLevel.Debug, textSource = source) From 595580f8b92e25637d2d111b6540bf52e7bfff11 Mon Sep 17 00:00:00 2001 From: pothemagicdragon Date: Sat, 11 Jul 2026 06:23:17 -0700 Subject: [PATCH 2/5] adds structure for details widget and adds a few bits of detail --- .../combat/autodisconnect/AutoDisconnect.kt | 274 +++++++++++++--- .../autodisconnect/AutoDisconnectScreen.kt | 12 +- .../autodisconnect/DisconnectDetailsWidget.kt | 295 ++++++++++++++++++ 3 files changed, 542 insertions(+), 39 deletions(-) create mode 100644 src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/DisconnectDetailsWidget.kt diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt index c4ca17648..38aba16b5 100644 --- a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt +++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt @@ -25,23 +25,27 @@ import com.lambda.event.events.PlayerEvent import com.lambda.event.events.TickEvent import com.lambda.event.events.WorldEvent import com.lambda.event.listener.SafeListener.Companion.listen +import com.lambda.gui.components.ClickGuiLayout import com.lambda.interaction.handlers.FriendHandler import com.lambda.module.Module import com.lambda.module.tag.ModuleTag import com.lambda.sound.SoundHandler.playSound import com.lambda.util.CommunicationUtils import com.lambda.util.CommunicationUtils.info -import com.lambda.util.CommunicationUtils.prefix +import com.lambda.util.EnchantmentUtils.forEachEnchantment import com.lambda.util.FormattingUtils.format import com.lambda.util.TickTimer import com.lambda.util.combat.CombatUtils.crystalDamage import com.lambda.util.combat.DamageUtils.isFallDeadly import com.lambda.util.extension.fullHealth -import com.lambda.util.extension.getBlockState import com.lambda.util.extension.tickDeltaF +import com.lambda.util.item.ItemStackUtils.bundleContents +import com.lambda.util.item.ItemStackUtils.shulkerBoxContents import com.lambda.util.player.PlayerUtils.isIn2b2tQueue import com.lambda.util.player.SlotUtils.allStacks import com.lambda.util.player.SlotUtils.armorSlots +import com.lambda.util.player.SlotUtils.hotbarStacks +import com.lambda.util.player.SlotUtils.inventoryStacks import com.lambda.util.text.buildText import com.lambda.util.text.color import com.lambda.util.text.highlighted @@ -54,8 +58,11 @@ import net.minecraft.client.network.ServerAddress import net.minecraft.client.network.ServerInfo import net.minecraft.client.texture.NativeImageBackedTexture import net.minecraft.client.util.ScreenshotRecorder +import net.minecraft.component.DataComponentTypes +import net.minecraft.enchantment.Enchantment import net.minecraft.entity.Entity import net.minecraft.entity.EntityType +import net.minecraft.entity.EquipmentSlot import net.minecraft.entity.damage.DamageSource import net.minecraft.entity.damage.DamageTypes import net.minecraft.entity.decoration.EndCrystalEntity @@ -63,6 +70,7 @@ import net.minecraft.entity.effect.StatusEffects import net.minecraft.entity.mob.CreeperEntity import net.minecraft.entity.player.PlayerEntity import net.minecraft.entity.projectile.ProjectileEntity +import net.minecraft.item.ItemStack import net.minecraft.item.Items import net.minecraft.network.message.LastSeenMessageList import net.minecraft.network.packet.c2s.play.ChatMessageC2SPacket @@ -88,12 +96,31 @@ object AutoDisconnect : Module( private const val MAX_CRYSTAL_DAMAGE_RANGE = 12.0 private const val CRYSTAL_TRIGGER_RANGE = 6.0 + + // Equipment slots shown per player, in display order (hands first, then armor). + private val EQUIPMENT_SLOTS = listOf( + "Main Hand" to EquipmentSlot.MAINHAND, + "Off Hand" to EquipmentSlot.OFFHAND, + "Head" to EquipmentSlot.HEAD, + "Chest" to EquipmentSlot.CHEST, + "Legs" to EquipmentSlot.LEGS, + "Feet" to EquipmentSlot.FEET, + ) + + // Armor slots shown in the local inventory's "Armor" group, top to bottom. + private val ARMOR_SLOTS = listOf( + EquipmentSlot.HEAD, + EquipmentSlot.CHEST, + EquipmentSlot.LEGS, + EquipmentSlot.FEET, + ) // Ticks to wait after (re)joining before re-arming, so triggers don't re-arm against a // world whose entities haven't synced in yet (there's no clean "entities loaded" signal). private const val JOIN_GRACE_TICKS = 20 private const val TRIGGERS_TAB = "Triggers" private const val GENERAL_TAB = "General" + private const val DISPLAY_TAB = "Display" private const val PACKET_DISCONNECT_GROUP = "Packet Disconnect Methods" private const val DAMAGE_TRIGGER_GROUP = "Damage Triggers" @@ -162,11 +189,14 @@ object AutoDisconnect : Module( @Tab(TRIGGERS_TAB) @Group(DAMAGE_TRIGGER_GROUP) private val arrow by setting("Arrow", false, "Disconnect from the server when you take arrow damage.") { damage } @Tab(TRIGGERS_TAB) @Group(DAMAGE_TRIGGER_GROUP) private val trident by setting("Trident", false, "Disconnect from the server when you take trident damage.") { damage } - @Tab(GENERAL_TAB) private val hideDetails by setting("Hide Details on Disconnect Screen", false, "Initially hide all details on the disconnect screen") @Tab(GENERAL_TAB) @Group(PACKET_DISCONNECT_GROUP) private val invalidHotbarDisconnect by setting("Select Invalid Hotbar Slot", false, "Sends an invalid hotbar selection to force the server to kick the player") @Tab(GENERAL_TAB) @Group(PACKET_DISCONNECT_GROUP) private val attackSelfDisconnect by setting("Attack Self", false, "Sends an attack self packet to force the server to kick the player") @Tab(GENERAL_TAB) @Group(PACKET_DISCONNECT_GROUP) private val impossibleTimestampChatDisconnect by setting("Send Impossible Chat Timestamp", false, "Sends a chat message with an impossible timestamp to force the server to kick the player") + @Tab(DISPLAY_TAB) private val hideDetails by setting("Hide Details on Disconnect Screen", false, "Initially hide all details on the disconnect screen") + @Tab(DISPLAY_TAB) private val showCoordinates by setting("Show Coordinates", true, "Show the player's coordinates on the disconnect screen") + @Tab(DISPLAY_TAB) private val showTime by setting("Show Time", true, "Show the time of disconnection on the disconnect screen") + private var disconnectDetails: DisconnectDetails? = null private var disconnectInProgress: Boolean = false private val joinTimer = TickTimer() @@ -276,7 +306,7 @@ object AutoDisconnect : Module( imageHeight = image.height, imageWidth = image.width, reason = reasonText, - details = disconnectScreenText(reasonText), + sections = disconnectSections(), hideDetails = hideDetails ) @@ -324,40 +354,214 @@ object AutoDisconnect : Module( return details } - private fun SafeContext.disconnectScreenText(text: Text) = buildText { - text(prefix(CommunicationUtils.LogLevel.Warn.logoColor)) - text(text) - literal("\n\n") - literal("Disconnected at ") - highlighted(player.pos.format()) - literal(" on ") - highlighted(CommunicationUtils.currentTime()) - literal(" with ") - highlighted(player.fullHealth.format()) - literal(" health.") - if (player.isSubmergedInWater) { - literal("\n") - literal("Submerged in water, had ") - highlighted("${player.air}") - literal(" ticks left of breath.") + private fun SafeContext.disconnectSections(): List { + val sections = mutableListOf() + + // Always-visible summary: the reason plus where/when/health it happened. + // Each detail is opt-in via the Display tab, so the sentence is assembled piecewise. + sections += DetailSection.TextSection( + buildText { + literal("Disconnected") + if (showCoordinates) { + literal(" at ") + highlighted(player.pos.format(separator = ", ", precision = 1)) + } + if (showTime) { + literal(" on ") + highlighted(CommunicationUtils.currentTime()) + } + literal(" with ") + highlighted(player.fullHealth.format(precision = 1)) + literal(" health.") + } + ) + + + // Environmental hazards, tucked into a collapsible section when present. + val hazards = buildList { + if (player.isSubmergedInWater) add( + buildText { + literal("Submerged in water, had ") + highlighted("${player.air}") + literal(" ticks left of breath.") + } + ) + if (player.isInLava) add( + buildText { literal("In lava") } + ) + if (player.isOnFire) add( + buildText { + literal("Burning for ") + highlighted("${player.fireTicks}") + literal(" more ticks.") + } + ) + } + if (hazards.isNotEmpty()) { + sections += DetailSection.CollapsibleSection( + header = Text.literal("Environment"), + body = buildText { + hazards.forEachIndexed { index, hazard -> + if (index > 0) literal("\n") + text(hazard) + } + } + ) + } + + // The local player's own inventory, grouped by area. + sections += DetailSection.CollapsibleSection( + header = Text.literal("Inventory"), + children = listOf( + itemGroup("Hotbar", player.hotbarStacks, player.inventory.selectedSlot), + itemGroup("Off Hand", listOf(player.getEquippedStack(EquipmentSlot.OFFHAND))), + itemGroup("Inventory", player.inventoryStacks), + itemGroup("Armor", ARMOR_SLOTS.map { player.getEquippedStack(it) }), + ), + expanded = false + ) + + // Every tracked player, each expandable into position plus per-item equipment. + val otherPlayers = world.players + .filter { it != player } + .sortedBy { player.distanceTo(it) } + if (otherPlayers.isNotEmpty()) { + sections += DetailSection.CollapsibleSection( + header = Text.literal("Players"), + children = otherPlayers.map { playerSection(it) }, + expanded = false + ) + } + + return sections + } + + /** + * A collapsible card for [other]: header of "name - distance", a body with its + * position, and one expandable child per non-empty equipment slot. + */ + private fun SafeContext.playerSection(other: PlayerEntity): DetailSection.CollapsibleSection { + fun slotEntry(label: String, slot: EquipmentSlot): DetailSection { + val stack = other.getEquippedStack(slot) + return if (stack.isEmpty) { + DetailSection.TextSection( + buildText { + literal("$label: ") + color(Color.GRAY) { literal("Empty") } + } + ) + } else { + itemRow(stack, label) + } } - if (player.isInLava) { - literal("\n") - literal("In lava, had ") - highlighted("${player.air}") - literal(" ticks left of breath.") + + val (armor, hands) = EQUIPMENT_SLOTS.partition { it.second in ARMOR_SLOTS } + + val equipment = hands.map { (label, slot) -> slotEntry(label, slot) } + + DetailSection.CollapsibleSection( + header = Text.literal("Armor"), + children = armor.map { (label, slot) -> slotEntry(label, slot) }, + expanded = false + ) + + return DetailSection.CollapsibleSection( + header = buildText { + text(other.name) + literal(" - ") + highlighted("${other.distanceTo(player).format(precision = 2)} blocks") + }, + body = buildText { + literal("Position: ") + highlighted(other.pos.format(separator = ", ", precision = 1)) + }, + children = equipment, + expanded = false + ) + } + + /** + * A collapsible group of inventory [stacks] (e.g. Hotbar, Armor). Empty stacks + * are dropped; an empty group shows an "Empty" note instead of children. The + * stack at [selectedIndex] (index into [stacks], before filtering) is marked + * as selected. + */ + private fun itemGroup(title: String, stacks: List, selectedIndex: Int? = null): DetailSection.CollapsibleSection { + val rows = stacks.mapIndexedNotNull { index, stack -> + if (stack.isEmpty) null else itemRow(stack, selected = index == selectedIndex) } - if (player.isOnFire) { - literal("\n") - literal("Burning for ") - highlighted("${player.fireTicks}") - literal(" ticks.") + + return DetailSection.CollapsibleSection( + header = Text.literal(title), + body = if (rows.isEmpty()) buildText { color(Color.GRAY) { literal("Empty") } } else null, + children = rows, + expanded = false + ) + } + + /** + * A row for a single [stack], optionally prefixed with a slot [label]. Expands + * only when it has something to show: enchantments (listed in the body) and/or + * shulker-box/bundle contents (listed as nested rows in the same format). + * Otherwise it's a plain line. + */ + private fun itemRow(stack: ItemStack, label: String? = null, selected: Boolean = false): DetailSection { + val header = itemHeader(stack, label, selected) + val enchantments = stack.forEachEnchantment { entry, level -> Enchantment.getName(entry, level) } + val contents = (stack.shulkerBoxContents + stack.bundleContents).filter { !it.isEmpty } + + if (enchantments.isEmpty() && contents.isEmpty()) { + return DetailSection.TextSection(header) } - if (isDisabled) { - color(Color.YELLOW) { - literal("\n\n") - literal("AutoDisconnect disabled.") + + return DetailSection.CollapsibleSection( + header = header, + body = if (enchantments.isEmpty()) { + null + } else { + buildText { + enchantments.forEachIndexed { index, enchantment -> + if (index > 0) literal("\n") + text(enchantment) + } + } + }, + children = contents.map { itemRow(it) }, + expanded = false + ) + } + + /** + * "{label}: {type} ({name}) ({remaining}/{max}) ({count})" for a stack: the + * [label] prefix only when given, the custom name in parentheses after the item + * type only when named, the durability (remaining/max) only when damageable, and + * the count in parentheses only when stackable. When [selected], appends a + * "(selected)" marker in the arrow color. + */ + private fun itemHeader(stack: ItemStack, label: String? = null, selected: Boolean = false): Text = buildText { + if (label != null) literal("$label: ") + + val customName = stack.get(DataComponentTypes.CUSTOM_NAME) + if (customName != null) { + text(stack.itemName) + color(ClickGuiLayout.textDisabled) { + literal(" (") + text(customName) + literal(")") } + } else { + text(stack.itemName) + } + + if (stack.isDamageable) { + color(ClickGuiLayout.textDisabled) { literal(" (${stack.maxDamage - stack.damage}/${stack.maxDamage})") } + } + + if (stack.maxCount > 1) { + color(ClickGuiLayout.textDisabled) { literal(" (${stack.count})") } + } + + if (selected) { + highlighted(" (selected)") } } @@ -508,7 +712,7 @@ data class DisconnectDetails( val imageHeight: Int, val imageWidth: Int, val reason: Text, - val details: Text, + val sections: List, val hideDetails: Boolean ) diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt index 45af58d8a..ace4e2e8e 100644 --- a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt +++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnectScreen.kt @@ -22,6 +22,7 @@ import com.lambda.util.render.CursorOverrideProvider import net.minecraft.client.gl.RenderPipelines import net.minecraft.client.gui.Click import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.cursor.Cursor import net.minecraft.client.gui.cursor.StandardCursors import net.minecraft.client.gui.screen.Screen import net.minecraft.client.gui.screen.TitleScreen @@ -29,21 +30,21 @@ import net.minecraft.client.gui.screen.multiplayer.ConnectScreen import net.minecraft.client.gui.screen.multiplayer.MultiplayerScreen import net.minecraft.client.gui.screen.world.SelectWorldScreen import net.minecraft.client.gui.widget.ButtonWidget -import net.minecraft.client.gui.widget.ScrollableTextWidget import net.minecraft.text.Text import kotlin.math.min class AutoDisconnectScreen( private val details: DisconnectDetails ) : Screen(Text.literal("Disconnected: ").append(details.reason)), - OverlayBackgroundScreen + OverlayBackgroundScreen, + CursorOverrideProvider { //state private val parent = TitleScreen() private var showDetails = !details.hideDetails //text - private lateinit var detailText: ScrollableTextWidget + private lateinit var detailText: DisconnectDetailsWidget //buttons private lateinit var toggleDetailsButton: ButtonWidget @@ -62,7 +63,7 @@ class AutoDisconnectScreen( super.init() detailText = addDrawableChild( - ScrollableTextWidget(0, 0, 0, 0, details.details, textRenderer) + DisconnectDetailsWidget(0, 0, 0, 0, details.sections, textRenderer) ) toggleDetailsButton = addButton(detailToggleText()) { showDetails = !showDetails; updateDetailVisibility() } @@ -96,6 +97,9 @@ class AutoDisconnectScreen( return super.mouseClicked(click, doubled) } + override fun getCursorOverride(mouseX: Int, mouseY: Int): Cursor? = + if (::detailText.isInitialized) detailText.hoverCursor(mouseX, mouseY) else null + override fun close() { releaseTexture() client?.setScreen(parent) diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/DisconnectDetailsWidget.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/DisconnectDetailsWidget.kt new file mode 100644 index 000000000..21e5793b8 --- /dev/null +++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/DisconnectDetailsWidget.kt @@ -0,0 +1,295 @@ +/* + * Copyright 2026 Lambda + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lambda.module.modules.combat.autodisconnect + +import com.lambda.gui.components.ClickGuiLayout +import com.lambda.util.text.buildText +import com.lambda.util.text.highlighted +import com.lambda.util.text.text +import net.minecraft.client.font.TextRenderer +import net.minecraft.client.gui.Click +import net.minecraft.client.gui.DrawContext +import net.minecraft.client.gui.cursor.Cursor +import net.minecraft.client.gui.cursor.StandardCursors +import net.minecraft.client.gui.screen.narration.NarrationMessageBuilder +import net.minecraft.client.gui.screen.narration.NarrationPart +import net.minecraft.client.gui.widget.ScrollableTextFieldWidget +import net.minecraft.text.OrderedText +import net.minecraft.text.Text +import java.util.* +import kotlin.math.roundToInt +import kotlin.math.sqrt + +/** + * A single entry in a [DisconnectDetailsWidget]. + */ +sealed interface DetailSection { + /** + * A plain block of wrapped text that is always shown. + */ + data class TextSection(val text: Text) : DetailSection + + /** + * A clickable [header] that toggles the visibility of its contents. When + * expanded it shows an optional [body] block followed by any nested + * [children], each indented one level further. Starts expanded when + * [expanded] is true. + */ + data class CollapsibleSection( + val header: Text, + val body: Text? = null, + val children: List = emptyList(), + val expanded: Boolean = true + ) : DetailSection +} + +/** + * A single vertically-scrollable widget that renders a mix of plain + * [DetailSection.TextSection]s and toggleable [DetailSection.CollapsibleSection]s. + * Collapsible headers expand/collapse in place; the whole stack shares one scrollbar. + */ +class DisconnectDetailsWidget( + x: Int, + y: Int, + width: Int, + height: Int, + private val sections: List, + private val textRenderer: TextRenderer +) : ScrollableTextFieldWidget(x, y, width, height, Text.literal("Disconnect details")) { + + /** + * Per-node expansion state, keyed by object identity so that structurally + * equal sections still toggle independently. + */ + private val expandedState = IdentityHashMap().apply { + fun visit(list: List) { + list.forEach { section -> + if (section is DetailSection.CollapsibleSection) { + put(section, section.expanded) + visit(section.children) + } + } + } + visit(sections) + } + + private fun contentWidth() = (width - padding).coerceAtLeast(1) + + private fun isExpanded(section: DetailSection.CollapsibleSection) = + expandedState[section] ?: section.expanded + + override fun getContentsHeight(): Int = + layout().lastOrNull()?.let { it.top + it.height } ?: 0 + + override fun getDeltaYPerScroll(): Double = LINE_HEIGHT.toDouble() + + override fun renderContents(context: DrawContext, mouseX: Int, mouseY: Int, deltaTicks: Float) { + val hovered = headerNodeAt(mouseX.toDouble(), mouseY.toDouble()) + + layout().forEach { placed -> + val top = textY + placed.top + + if (hovered != null && placed.node === hovered) { + fillRounded(context, placed.left - HOVER_PADDING, top - HOVER_PADDING, placed.left + placed.textWidth + HOVER_PADDING, top + placed.height, HOVER_RADIUS, COLOR_HOVER_BACKGROUND) + } + + val color = if (placed.node == null) COLOR_TEXT else COLOR_HEADER + drawTextLines(context, placed.lines, placed.left, top, color) + } + } + + /** X of the row's left text edge, in widget space. */ + private val Placed.left get() = textX + indent + + /** Width of the row's widest wrapped line. */ + private val Placed.textWidth get() = lines.maxOf { textRenderer.getWidth(it) } + + override fun drawBox(context: DrawContext) { + val right = x + width + val bottomEdge = y + height + context.fill(x, y, right, bottomEdge, COLOR_BACKGROUND) + context.fill(x, y, right, y + 1, COLOR_BORDER) // top + context.fill(x, bottomEdge - 1, right, bottomEdge, COLOR_BORDER) // bottom + context.fill(x, y, x + 1, bottomEdge, COLOR_BORDER) // left + context.fill(right - 1, y, right, bottomEdge, COLOR_BORDER) // right + } + + /** + * The cursor to show for the point ([mouseX], [mouseY]) in screen space: a + * pointing hand when hovering a collapsible header, otherwise null. The owning + * screen must surface this via [com.lambda.util.render.CursorOverrideProvider], + * since the game render loop resets the cursor every frame. + */ + fun hoverCursor(mouseX: Int, mouseY: Int): Cursor? = + if (visible && headerNodeAt(mouseX.toDouble(), mouseY.toDouble()) != null) { + StandardCursors.POINTING_HAND + } else { + null + } + + override fun mouseClicked(click: Click, doubled: Boolean): Boolean { + if (visible && click.button() == 0) { + val node = headerNodeAt(click.x(), click.y()) + if (node != null) { + expandedState[node] = !isExpanded(node) + refreshScroll() + return true + } + } + + return super.mouseClicked(click, doubled) + } + + override fun appendClickableNarrations(builder: NarrationMessageBuilder) { + builder.put(NarrationPart.TITLE, message) + } + + private fun drawTextLines(context: DrawContext, lines: List, x: Int, y: Int, color: Int) { + var lineY = y + lines.forEach { line -> + context.drawTextWithShadow(textRenderer, line, x, lineY, color) + lineY += LINE_HEIGHT + } + } + + /** + * Fills a rectangle with rounded corners by insetting each corner row along + * the circle equation. Aliased at the corners, which is fine for a small, + * translucent highlight; [DrawContext] has no rounded-rect primitive. + */ + @Suppress("SameParameterValue") + private fun fillRounded(context: DrawContext, left: Int, top: Int, right: Int, bottom: Int, radius: Int, color: Int) { + val r = radius.coerceIn(0, minOf((right - left) / 2, (bottom - top) / 2)) + if (r <= 0) { + context.fill(left, top, right, bottom, color) + return + } + + context.fill(left, top + r, right, bottom - r, color) + for (dy in 0 until r) { + val inset = r - sqrt((r * r - (r - dy) * (r - dy)).toDouble()).roundToInt() + context.fill(left + inset, top + dy, right - inset, top + dy + 1, color) + context.fill(left + inset, bottom - dy - 1, right - inset, bottom - dy, color) + } + } + + /** + * Returns the collapsible section whose header row contains the point + * ([x], [y]) in screen space, or null if none does. + */ + private fun headerNodeAt(x: Double, y: Double): DetailSection.CollapsibleSection? { + if (y < getY() || y >= bottom) return null + + layout().forEach { placed -> + if (placed.node != null) { + val top = textY + placed.top - scrollY + val bottom = top + placed.height + if (x >= placed.left && x < placed.left + placed.textWidth && y >= top && y < bottom) return placed.node + } + } + + return null + } + + /** + * Flattens the section tree into positioned rows for the current width and + * expansion state. Collapsed sections contribute only their header; their + * body and children are skipped entirely. Cheap enough to recompute per use + * given how few sections a disconnect screen holds. Returns their position + * relative to the top of the scrollable content, not the screen. + */ + private fun layout(): List { + val placements = ArrayList() + var y = 0 + + fun emit(node: DetailSection.CollapsibleSection?, indent: Int, lines: List, gap: Int) { + placements += Placed(node, indent, y, lines, lines.size * LINE_HEIGHT) + y += lines.size * LINE_HEIGHT + gap + } + + fun place(list: List, depth: Int) { + val indent = depth * NEST_INDENT + val width = (contentWidth() - indent).coerceAtLeast(1) + + list.forEach { section -> + when (section) { + is DetailSection.TextSection -> + emit(null, indent, textRenderer.wrapLines(section.text, width), SECTION_SPACING) + + is DetailSection.CollapsibleSection -> { + val expanded = isExpanded(section) + val arrow = if (expanded) ARROW_EXPANDED else ARROW_COLLAPSED + val headerLines = textRenderer.wrapLines( + buildText { + text(section.header) + highlighted(arrow) + }, + width + ) + val hasBody = expanded && section.body != null + emit(section, indent, headerLines, if (hasBody) HEADER_BODY_GAP else SECTION_SPACING) + + if (expanded) { + section.body?.let { body -> + val childIndent = (depth + 1) * NEST_INDENT + val bodyWidth = (contentWidth() - childIndent).coerceAtLeast(1) + emit(null, childIndent, textRenderer.wrapLines(body, bodyWidth), SECTION_SPACING) + } + place(section.children, depth + 1) + } + } + } + } + } + + place(sections, 0) + return placements + } + + /** + * A single positioned row of wrapped lines. [node] is non-null only for + * clickable headers; text and body blocks leave it null. + */ + private data class Placed( + val node: DetailSection.CollapsibleSection?, + val indent: Int, + val top: Int, + val lines: List, + val height: Int + ) + + private companion object { + const val LINE_HEIGHT = 9 + const val SECTION_SPACING = 6 + const val HEADER_BODY_GAP = 2 + const val NEST_INDENT = 8 + const val ARROW_EXPANDED = " [-]" + const val ARROW_COLLAPSED = " [+]" + const val HOVER_PADDING = 2 + const val HOVER_RADIUS = 1 + + // Pulled live from the click GUI theme so the panel matches the user's colors. + val COLOR_TEXT get() = ClickGuiLayout.text.rgb + val COLOR_BACKGROUND get() = ClickGuiLayout.windowBg.rgb + val COLOR_BORDER get() = ClickGuiLayout.border.rgb + val COLOR_HOVER_BACKGROUND get() = ClickGuiLayout.headerHovered.rgb + + // Header text tinted by compositing the highlight color over the text color. + val COLOR_HEADER get() = ClickGuiLayout.text.rgb//composite(COLOR_TEXT, COLOR_HOVER_BACKGROUND) + } +} From b6872eea6e4667048505993b397b2b9ddb3163cc Mon Sep 17 00:00:00 2001 From: pothemagicdragon Date: Sat, 11 Jul 2026 13:43:34 -0700 Subject: [PATCH 3/5] adds more details including what triggers are disarmed, server details, nearby entities, & online players --- .../combat/autodisconnect/AutoDisconnect.kt | 254 +++++++++++++++--- 1 file changed, 212 insertions(+), 42 deletions(-) diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt index 38aba16b5..7646f5e10 100644 --- a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt +++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt @@ -34,6 +34,8 @@ import com.lambda.util.CommunicationUtils import com.lambda.util.CommunicationUtils.info import com.lambda.util.EnchantmentUtils.forEachEnchantment import com.lambda.util.FormattingUtils.format +import com.lambda.util.ServerTPSUtils +import com.lambda.util.SpeedUnit import com.lambda.util.TickTimer import com.lambda.util.combat.CombatUtils.crystalDamage import com.lambda.util.combat.DamageUtils.isFallDeadly @@ -42,6 +44,7 @@ import com.lambda.util.extension.tickDeltaF import com.lambda.util.item.ItemStackUtils.bundleContents import com.lambda.util.item.ItemStackUtils.shulkerBoxContents import com.lambda.util.player.PlayerUtils.isIn2b2tQueue +import com.lambda.util.player.MovementUtils.moveDelta import com.lambda.util.player.SlotUtils.allStacks import com.lambda.util.player.SlotUtils.armorSlots import com.lambda.util.player.SlotUtils.hotbarStacks @@ -63,6 +66,7 @@ import net.minecraft.enchantment.Enchantment import net.minecraft.entity.Entity import net.minecraft.entity.EntityType import net.minecraft.entity.EquipmentSlot +import net.minecraft.entity.LivingEntity import net.minecraft.entity.damage.DamageSource import net.minecraft.entity.damage.DamageTypes import net.minecraft.entity.decoration.EndCrystalEntity @@ -226,7 +230,8 @@ object AutoDisconnect : Module( } if (matchingReason != null) { val reasonText = matchingReason.generateReason(this) ?: buildText { literal("Reason lost to the abyss") } - if (requestDisconnect(reasonText) && matchingReason.smartToggle()) { + val willDisarm = matchingReason.smartToggle() + if (requestDisconnect(reasonText, matchingReason.takeIf { willDisarm }) && willDisarm) { matchingReason.armed = false } } @@ -294,7 +299,7 @@ object AutoDisconnect : Module( * through. Returns false (and does nothing) when the player isn't in a survival-like * game mode or a disconnect is already in progress. */ - private fun SafeContext.requestDisconnect(reasonText: Text): Boolean { + private fun SafeContext.requestDisconnect(reasonText: Text, disarmedTrigger: Reason? = null): Boolean { if (player.gameMode != GameMode.SURVIVAL && player.gameMode != GameMode.ADVENTURE || disconnectInProgress) return false disconnectInProgress = true ScreenshotRecorder.takeScreenshot(Lambda.mc.framebuffer, 1) { image -> @@ -305,8 +310,13 @@ object AutoDisconnect : Module( imageIdentifier = imageIdentifier, imageHeight = image.height, imageWidth = image.width, - reason = reasonText, - sections = disconnectSections(), + reason = if (disarmedTrigger != null) buildText { + text(reasonText) + literal(" (") + highlighted("${disarmedTrigger.displayName} trigger disarmed") + literal(")") + } else reasonText, + sections = disconnectSections(disarmedTrigger), hideDetails = hideDetails ) @@ -354,28 +364,49 @@ object AutoDisconnect : Module( return details } - private fun SafeContext.disconnectSections(): List { + private fun SafeContext.disconnectSections(disarmedTrigger: Reason? = null): List { val sections = mutableListOf() - // Always-visible summary: the reason plus where/when/health it happened. - // Each detail is opt-in via the Display tab, so the sentence is assembled piecewise. - sections += DetailSection.TextSection( - buildText { - literal("Disconnected") - if (showCoordinates) { - literal(" at ") - highlighted(player.pos.format(separator = ", ", precision = 1)) + // When this disconnect disarmed a smart trigger, call it out on the first line. + if (disarmedTrigger != null) { + sections += DetailSection.TextSection( + buildText { + literal("Disarmed the ") + highlighted(disarmedTrigger.displayName) + literal(" smart trigger.") } - if (showTime) { - literal(" on ") - highlighted(CommunicationUtils.currentTime()) + ) + } + + // Always-visible summary: the reason plus when it happened. + if (showTime) { + sections += DetailSection.TextSection( + buildText { + literal("Disconnected") + literal(" on ") + highlighted(CommunicationUtils.currentTime()) + literal(".") } - literal(" with ") - highlighted(player.fullHealth.format(precision = 1)) - literal(" health.") - } - ) + ) + } + // Smart triggers and their armed state, one child per active trigger with its smart toggle on. + val smartReasons = Reason.entries.filter { it.enabled() && it.smartToggle() } + if (smartReasons.isNotEmpty()) { + sections += DetailSection.CollapsibleSection( + header = Text.literal("Smart Triggers"), + children = smartReasons.map { reason -> + DetailSection.TextSection( + buildText { + literal("${reason.displayName}: ") + if (reason.armed) color(Color.GREEN) { literal("Armed") } + else highlighted("Disarmed") + } + ) + }, + expanded = true + ) + } // Environmental hazards, tucked into a collapsible section when present. val hazards = buildList { @@ -397,45 +428,147 @@ object AutoDisconnect : Module( } ) } - if (hazards.isNotEmpty()) { - sections += DetailSection.CollapsibleSection( - header = Text.literal("Environment"), - body = buildText { - hazards.forEachIndexed { index, hazard -> - if (index > 0) literal("\n") - text(hazard) - } + // The local player: location, health, speed, environmental hazards, and inventory. + sections += DetailSection.CollapsibleSection( + header = Text.literal("Player"), + children = buildList { + if (showCoordinates) add( + DetailSection.TextSection( + buildText { + literal("Coordinates: ") + highlighted(player.pos.format(separator = ", ", precision = 1)) + } + ) + ) + add( + DetailSection.TextSection( + buildText { + literal("Health: ") + highlighted(player.fullHealth.format(precision = 1)) + } + ) + ) + add( + DetailSection.TextSection( + buildText { + literal("Speed: ") + highlighted(SpeedUnit.BlocksPerSecond.convertFromMinecraft(player.moveDelta).format(precision = 1)) + literal(" ${SpeedUnit.BlocksPerSecond.unitName}") + } + ) + ) + if (hazards.isNotEmpty()) add( + DetailSection.CollapsibleSection( + header = Text.literal("Environment"), + body = buildText { + hazards.forEachIndexed { index, hazard -> + if (index > 0) literal("\n") + text(hazard) + } + } + ) + ) + add( + DetailSection.CollapsibleSection( + header = Text.literal("Inventory"), + children = listOf( + itemGroup("Hotbar", player.hotbarStacks, player.inventory.selectedSlot), + itemGroup("Off Hand", listOf(player.getEquippedStack(EquipmentSlot.OFFHAND))), + itemGroup("Inventory", player.inventoryStacks), + itemGroup("Armor", ARMOR_SLOTS.map { player.getEquippedStack(it) }), + ), + expanded = false + ) + ) + }, + expanded = true + ) + + // Server-side metrics, below the player and collapsed by default; each line is opt-in via the Display tab. + val serverDetails = buildList { + add(DetailSection.TextSection( + buildText { + literal("Ping: ") + val ping = connection.getPlayerListEntry(player.uuid)?.latency ?: -1 + if (ping >= 0) highlighted("$ping ms") else highlighted("unknown") + } + )) + add(DetailSection.TextSection( + buildText { + literal("TPS: ") + val tps = ServerTPSUtils.recentData(ServerTPSUtils.TickFormat.Tps).lastOrNull() + if (tps != null) { + highlighted(tps.format(precision = 1)) + literal(" t/s") + } else highlighted("unknown") + } + )) + add(DetailSection.TextSection( + buildText { + literal("In-game time: ") + highlighted(formatDayTime(world.timeOfDay)) } + )) + } + if (serverDetails.isNotEmpty()) { + sections += DetailSection.CollapsibleSection( + header = Text.literal("Server Details"), + children = serverDetails, + expanded = false ) } - // The local player's own inventory, grouped by area. - sections += DetailSection.CollapsibleSection( - header = Text.literal("Inventory"), - children = listOf( - itemGroup("Hotbar", player.hotbarStacks, player.inventory.selectedSlot), - itemGroup("Off Hand", listOf(player.getEquippedStack(EquipmentSlot.OFFHAND))), - itemGroup("Inventory", player.inventoryStacks), - itemGroup("Armor", ARMOR_SLOTS.map { player.getEquippedStack(it) }), - ), - expanded = false - ) - // Every tracked player, each expandable into position plus per-item equipment. val otherPlayers = world.players .filter { it != player } .sortedBy { player.distanceTo(it) } if (otherPlayers.isNotEmpty()) { sections += DetailSection.CollapsibleSection( - header = Text.literal("Players"), + header = Text.literal("Nearby Players"), children = otherPlayers.map { playerSection(it) }, expanded = false ) } + // Every loaded non-player entity, nearest first. Players are covered by the Nearby Players section. + val nearbyEntities = world.entities + .filter { it !is PlayerEntity } + .sortedBy { player.distanceTo(it) } + if (nearbyEntities.isNotEmpty()) { + sections += DetailSection.CollapsibleSection( + header = Text.literal("Nearby Entities"), + children = nearbyEntities.map { entitySection(it) }, + expanded = false + ) + } + + // Everyone on the server tab list, whether or not they're within render distance. + val onlinePlayers = connection.listedPlayerListEntries + .map { it.profile.name } + .sorted() + if (onlinePlayers.isNotEmpty()) { + sections += DetailSection.CollapsibleSection( + header = Text.literal("Online Players"), + children = onlinePlayers.map { name -> + DetailSection.TextSection(buildText { literal(name) }) + }, + expanded = false + ) + } + return sections } + /** + * Formats a world [timeOfDay] (in ticks) as a 24-hour in-game clock. Tick 0 is + * 06:00, so the day-fraction is offset by 6000 ticks before scaling to minutes. + */ + private fun formatDayTime(timeOfDay: Long): String { + val dayTick = ((timeOfDay % 24000L) + 24000L) % 24000L + val minuteOfDay = ((dayTick + 6000L) % 24000L) * 24 * 60 / 24000 + return "%02d:%02d".format(minuteOfDay / 60, minuteOfDay % 60) + } + /** * A collapsible card for [other]: header of "name - distance", a body with its * position, and one expandable child per non-empty equipment slot. @@ -479,6 +612,43 @@ object AutoDisconnect : Module( ) } + /** + * A row for a single [entity]: header of "type - distance". Expands to its held + * items and armor plus any active status effects (and burning) when it has any; + * otherwise it's a plain line. Non-living entities (items, projectiles, …) never + * have equipment or effects, so they render as plain lines. + */ + private fun SafeContext.entitySection(entity: Entity): DetailSection { + val header = buildText { + text(entity.type.name) + literal(" - ") + highlighted("${entity.distanceTo(player).format(precision = 2)} blocks") + } + + val children = buildList { + if (entity is LivingEntity) { + EQUIPMENT_SLOTS.forEach { (label, slot) -> + val stack = entity.getEquippedStack(slot) + if (!stack.isEmpty) add(itemRow(stack, label)) + } + entity.statusEffects.forEach { effect -> + add( + DetailSection.TextSection( + buildText { + text(effect.effectType.value().name) + if (effect.amplifier > 0) literal(" ${effect.amplifier + 1}") + } + ) + ) + } + } + if (entity.isOnFire) add(DetailSection.TextSection(buildText { literal("Burning") })) + } + + return if (children.isEmpty()) DetailSection.TextSection(header) + else DetailSection.CollapsibleSection(header = header, children = children, expanded = false) + } + /** * A collapsible group of inventory [stacks] (e.g. Hotbar, Armor). Empty stacks * are dropped; an empty group shows an "Empty" note instead of children. The From 3ae8eeaf43c7969105df48febc6048ac2a8ff9ed Mon Sep 17 00:00:00 2001 From: pothemagicdragon Date: Sat, 11 Jul 2026 13:52:14 -0700 Subject: [PATCH 4/5] adds potion & other effects --- .../combat/autodisconnect/AutoDisconnect.kt | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt index 7646f5e10..f52f30651 100644 --- a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt +++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt @@ -70,6 +70,7 @@ import net.minecraft.entity.LivingEntity import net.minecraft.entity.damage.DamageSource import net.minecraft.entity.damage.DamageTypes import net.minecraft.entity.decoration.EndCrystalEntity +import net.minecraft.entity.effect.StatusEffectInstance import net.minecraft.entity.effect.StatusEffects import net.minecraft.entity.mob.CreeperEntity import net.minecraft.entity.player.PlayerEntity @@ -468,6 +469,7 @@ object AutoDisconnect : Module( } ) ) + effectsSection(player)?.let { add(it) } add( DetailSection.CollapsibleSection( header = Text.literal("Inventory"), @@ -595,7 +597,8 @@ object AutoDisconnect : Module( header = Text.literal("Armor"), children = armor.map { (label, slot) -> slotEntry(label, slot) }, expanded = false - ) + ) + + listOfNotNull(effectsSection(other)) return DetailSection.CollapsibleSection( header = buildText { @@ -614,9 +617,9 @@ object AutoDisconnect : Module( /** * A row for a single [entity]: header of "type - distance". Expands to its held - * items and armor plus any active status effects (and burning) when it has any; - * otherwise it's a plain line. Non-living entities (items, projectiles, …) never - * have equipment or effects, so they render as plain lines. + * items and armor plus an "Effects" dropdown of active potion effects and related + * states (burning, freezing) when it has any; otherwise it's a plain line. + * Non-living entities (items, projectiles, …) still surface burning/freezing. */ private fun SafeContext.entitySection(entity: Entity): DetailSection { val header = buildText { @@ -631,22 +634,48 @@ object AutoDisconnect : Module( val stack = entity.getEquippedStack(slot) if (!stack.isEmpty) add(itemRow(stack, label)) } + } + + effectsSection(entity)?.let { add(it) } + } + + return if (children.isEmpty()) DetailSection.TextSection(header) + else DetailSection.CollapsibleSection(header = header, children = children, expanded = false) + } + + /** + * A collapsible "Effects" section listing [entity]'s active potion effects plus + * related non-potion states (burning, freezing), or null when it has none. + */ + private fun effectsSection(entity: Entity): DetailSection.CollapsibleSection? { + val effects = buildList { + if (entity is LivingEntity) { entity.statusEffects.forEach { effect -> add( - DetailSection.TextSection( - buildText { - text(effect.effectType.value().name) - if (effect.amplifier > 0) literal(" ${effect.amplifier + 1}") - } - ) + buildText { + text(effect.effectType.value().name) + if (effect.amplifier > 0) literal(" ${effect.amplifier + 1}") + color(ClickGuiLayout.textDisabled) { literal(" (${effectDuration(effect)})") } + } ) } } - if (entity.isOnFire) add(DetailSection.TextSection(buildText { literal("Burning") })) + if (entity.isOnFire) add(buildText { literal("Burning") }) + if (entity.isFrozen) add(buildText { literal("Freezing") }) } + if (effects.isEmpty()) return null + return DetailSection.CollapsibleSection( + header = Text.literal("Effects"), + children = effects.map { DetailSection.TextSection(it) }, + expanded = false + ) + } - return if (children.isEmpty()) DetailSection.TextSection(header) - else DetailSection.CollapsibleSection(header = header, children = children, expanded = false) + /** Formats a status effect's remaining time as m:ss, or ∞ for infinite effects. */ + private fun effectDuration(effect: StatusEffectInstance): String { + if (effect.isInfinite) return "∞" + val seconds = effect.duration / 20 + return "%d:%02d".format(seconds / 60, seconds % 60) } /** From 465b86e102a10209d3802853aeb06130cb4bd361 Mon Sep 17 00:00:00 2001 From: pothemagicdragon Date: Sun, 12 Jul 2026 20:07:34 -0600 Subject: [PATCH 5/5] Can you see it? --- .../module/modules/combat/autodisconnect/AutoDisconnect.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt index f52f30651..c479ac4b9 100644 --- a/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt +++ b/src/main/kotlin/com/lambda/module/modules/combat/autodisconnect/AutoDisconnect.kt @@ -382,7 +382,7 @@ object AutoDisconnect : Module( // Always-visible summary: the reason plus when it happened. if (showTime) { sections += DetailSection.TextSection( - buildText { + buildText { literal("Disconnected") literal(" on ") highlighted(CommunicationUtils.currentTime())