diff --git a/src/main/java/meteordevelopment/meteorclient/events/game/DisconnectReasonEvent.java b/src/main/java/meteordevelopment/meteorclient/events/game/DisconnectReasonEvent.java new file mode 100644 index 00000000000..951ca6c2dbb --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/events/game/DisconnectReasonEvent.java @@ -0,0 +1,15 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.events.game; + +/** Posted the moment the client learns why it's about to disconnect, before {@link GameLeftEvent} fires. */ +public class DisconnectReasonEvent { + public final String reason; + + public DisconnectReasonEvent(String reason) { + this.reason = reason == null ? "" : reason; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/AddFlowNodeScreen.java b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/AddFlowNodeScreen.java new file mode 100644 index 00000000000..ac3c7e76fe6 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/AddFlowNodeScreen.java @@ -0,0 +1,57 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.gui.screens.baritoneflow; + +import meteordevelopment.meteorclient.gui.GuiTheme; +import meteordevelopment.meteorclient.gui.WindowScreen; +import meteordevelopment.meteorclient.gui.widgets.containers.WSection; +import meteordevelopment.meteorclient.gui.widgets.pressable.WButton; +import meteordevelopment.meteorclient.systems.baritoneflow.Flow; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNode; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNodeType; + +/** + * Picker shown when right-clicking empty canvas in the {@link FlowEditorScreen}. Adds a node of the + * chosen type at the click position. + */ +public class AddFlowNodeScreen extends WindowScreen { + private final Flow flow; + private final int x, y; + + public AddFlowNodeScreen(GuiTheme theme, Flow flow, int x, int y) { + super(theme, "Add Node"); + + this.flow = flow; + this.x = x; + this.y = y; + } + + @Override + public void initWidgets() { + WSection triggers = add(theme.section("Triggers")).expandX().widget(); + WSection baritone = add(theme.section("Baritone Tasks")).expandX().widget(); + WSection modules = add(theme.section("Meteor Modules")).expandX().widget(); + WSection client = add(theme.section("Client Actions")).expandX().widget(); + + for (FlowNodeType type : FlowNodeType.values()) { + if (type == FlowNodeType.Start) continue; // only one Start node per flow + + WSection section = switch (type.category()) { + case Trigger -> triggers; + case Module -> modules; + case Client -> client; + case Baritone -> baritone; + }; + + WButton button = section.add(theme.button(type.label())).expandX().widget(); + button.action = () -> { + FlowNode node = flow.addNode(x, y); + node.type.set(type); + onClose(); + }; + } + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/DisconnectLogScreen.java b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/DisconnectLogScreen.java new file mode 100644 index 00000000000..87b7b250bc3 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/DisconnectLogScreen.java @@ -0,0 +1,55 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.gui.screens.baritoneflow; + +import meteordevelopment.meteorclient.gui.GuiTheme; +import meteordevelopment.meteorclient.gui.WindowScreen; +import meteordevelopment.meteorclient.gui.widgets.containers.WHorizontalList; +import meteordevelopment.meteorclient.gui.widgets.pressable.WButton; +import meteordevelopment.meteorclient.systems.baritoneflow.DisconnectLog; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** Lists every disconnect reason recorded by {@link DisconnectLog}, newest first, with a way to clear the history. */ +public class DisconnectLogScreen extends WindowScreen { + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + + public DisconnectLogScreen(GuiTheme theme) { + super(theme, "Disconnect Log"); + } + + @Override + public void initWidgets() { + DisconnectLog log = DisconnectLog.get(); + + WHorizontalList top = add(theme.horizontalList()).expandX().widget(); + top.add(theme.label(log.getAll().size() + " recorded disconnect(s).")).expandCellX().widget(); + + WButton clear = top.add(theme.button("Clear")).widget(); + clear.action = () -> { + log.clear(); + reload(); + }; + + add(theme.horizontalSeparator()).expandX(); + + if (log.getAll().isEmpty()) { + add(theme.label("No disconnects recorded yet.")); + return; + } + + for (DisconnectLog.Entry entry : log.getAll()) { + WHorizontalList row = add(theme.horizontalList()).expandX().widget(); + + row.add(theme.label(TIME_FORMAT.format(Instant.ofEpochMilli(entry.time())))).widget(); + + String reason = entry.reason().isBlank() ? "(unknown)" : entry.reason().replace("\n", " "); + row.add(theme.label(reason)).expandCellX().widget(); + } + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowEditorScreen.java b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowEditorScreen.java new file mode 100644 index 00000000000..0374452accb --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowEditorScreen.java @@ -0,0 +1,267 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.gui.screens.baritoneflow; + +import meteordevelopment.meteorclient.gui.GuiTheme; +import meteordevelopment.meteorclient.gui.WidgetScreen; +import meteordevelopment.meteorclient.renderer.Renderer2D; +import meteordevelopment.meteorclient.renderer.text.VanillaTextRenderer; +import meteordevelopment.meteorclient.systems.baritoneflow.BaritoneFlows; +import meteordevelopment.meteorclient.systems.baritoneflow.Flow; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNode; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNodeType; +import meteordevelopment.meteorclient.systems.modules.Modules; +import meteordevelopment.meteorclient.systems.modules.baritone.FlowBuilder; +import meteordevelopment.meteorclient.utils.render.color.Color; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import org.lwjgl.glfw.GLFW; + +import static meteordevelopment.meteorclient.MeteorClient.mc; + +/** + * n8n-style visual editor for a {@link Flow}. Nodes are draggable boxes; drag from a node's right + * (output) port onto another node to wire them together. Right-click a node to edit it, right-click + * empty space to add one. Rendered directly with {@link Renderer2D} like the HUD editor. + */ +public class FlowEditorScreen extends WidgetScreen { + private static final int NODE_W = 172; + private static final int NODE_H = 48; + private static final int PORT = 8; + + private static final Color BG = new Color(38, 40, 52, 220); + private static final Color BG_HOVER = new Color(54, 57, 72, 230); + private static final Color BG_CURRENT = new Color(70, 62, 30, 235); + private static final Color OL = new Color(120, 124, 145, 255); + private static final Color OL_START = new Color(90, 190, 110, 255); + private static final Color OL_TRIGGER = new Color(150, 110, 235, 255); + private static final Color OL_CURRENT = new Color(235, 190, 70, 255); + private static final Color PORT_COLOR = new Color(150, 155, 190, 255); + private static final Color LINE = new Color(150, 155, 180, 220); + private static final Color TITLE = new Color(235, 236, 245, 255); + private static final Color SUBTITLE = new Color(170, 174, 190, 255); + private static final Color HELP = new Color(200, 202, 214, 190); + + private final Flow flow; + + private int lastMouseX, lastMouseY; + + private FlowNode draggingNode; + private int dragOffsetX, dragOffsetY; + private FlowNode connectingFrom; + + public FlowEditorScreen(GuiTheme theme, Flow flow) { + super(theme, "Flow: " + flow.name); + this.flow = flow; + } + + @Override + public void initWidgets() { + // Everything is custom-rendered; no widgets on the canvas itself. + } + + // Input + + @Override + public boolean mouseClicked(MouseButtonEvent click, boolean doubled) { + int mx = scale(click.x()); + int my = scale(click.y()); + + if (click.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + FlowNode port = getOutputPortAt(mx, my); + if (port != null) { + connectingFrom = port; + return true; + } + + FlowNode node = getNodeAt(mx, my); + if (node != null) { + draggingNode = node; + dragOffsetX = mx - node.x; + dragOffsetY = my - node.y; + } + } else if (click.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT) { + FlowNode node = getNodeAt(mx, my); + if (node != null) mc.setScreen(new FlowNodeScreen(theme, flow, node)); + else mc.setScreen(new AddFlowNodeScreen(theme, flow, mx, my)); + return true; + } + + return false; + } + + @Override + public void mouseMoved(double mouseX, double mouseY) { + lastMouseX = scale(mouseX); + lastMouseY = scale(mouseY); + + if (draggingNode != null) { + draggingNode.x = lastMouseX - dragOffsetX; + draggingNode.y = lastMouseY - dragOffsetY; + } + } + + @Override + public boolean mouseReleased(MouseButtonEvent click) { + int mx = scale(click.x()); + int my = scale(click.y()); + + if (click.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT) { + if (connectingFrom != null) { + FlowNode target = getNodeAt(mx, my); + if (target != null && target != connectingFrom) { + // Toggle: wire them up, or remove an existing wire. + if (connectingFrom.children.contains(target.id)) flow.disconnect(connectingFrom, target); + else flow.connect(connectingFrom, target); + save(); + } + connectingFrom = null; + } + + if (draggingNode != null) { + draggingNode = null; + save(); + } + } + + return false; + } + + @Override + public boolean keyPressed(KeyEvent input) { + switch (input.key()) { + case GLFW.GLFW_KEY_DELETE -> { + FlowNode node = getNodeAt(lastMouseX, lastMouseY); + if (node != null) { + flow.removeNode(node); + save(); + return true; + } + } + case GLFW.GLFW_KEY_R -> { + FlowBuilder module = Modules.get().get(FlowBuilder.class); + if (module != null) module.runFlow(flow); + return true; + } + case GLFW.GLFW_KEY_X -> { + FlowBuilder module = Modules.get().get(FlowBuilder.class); + if (module != null) module.stopAll(); + return true; + } + case GLFW.GLFW_KEY_A -> { + flow.armed = !flow.armed; + save(); + return true; + } + } + + return super.keyPressed(input); + } + + // Rendering + + @Override + protected void onRenderBefore(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) { + FlowNode current = currentNode(); + FlowNode hovered = getNodeAt(mouseX, mouseY); + + Renderer2D r = Renderer2D.COLOR; + r.begin(); + + // Connections + for (FlowNode node : flow.nodes) { + double ox = node.x + NODE_W, oy = node.y + NODE_H / 2.0; + for (int childId : node.children) { + FlowNode child = flow.getById(childId); + if (child == null) continue; + r.line(ox, oy, child.x, child.y + NODE_H / 2.0, LINE); + } + } + + // Wire currently being dragged + if (connectingFrom != null) { + r.line(connectingFrom.x + NODE_W, connectingFrom.y + NODE_H / 2.0, lastMouseX, lastMouseY, LINE); + } + + // Nodes + for (FlowNode node : flow.nodes) { + boolean isStart = node.type.get() == FlowNodeType.Start; + boolean isTrigger = node.type.get().category() == FlowNodeType.Category.Trigger; + Color bg = node == current ? BG_CURRENT : (node == hovered ? BG_HOVER : BG); + Color ol = node == current ? OL_CURRENT : (isStart ? OL_START : (isTrigger ? OL_TRIGGER : OL)); + + box(r, node.x, node.y, NODE_W, NODE_H, bg, ol); + + // Ports + r.quad(node.x - PORT / 2.0, node.y + NODE_H / 2.0 - PORT / 2.0, PORT, PORT, PORT_COLOR); + r.quad(node.x + NODE_W - PORT / 2.0, node.y + NODE_H / 2.0 - PORT / 2.0, PORT, PORT, PORT_COLOR); + } + + r.render(); + + // Text + VanillaTextRenderer text = VanillaTextRenderer.INSTANCE; + text.begin(1, false, false); + + text.render("Flow: " + flow.name + (flow.armed ? " [ARMED]" : ""), 8, 8, TITLE, true); + text.render("Left-drag node / wire ports - Right-click: edit or add - Delete: remove - R: run - X: stop - A: arm/disarm", 8, 26, HELP, true); + + for (FlowNode node : flow.nodes) { + text.render(node.title(), node.x + 8, node.y + 7, TITLE, false); + String summary = clip(node.summary(), 22); + if (!summary.isEmpty()) text.render(summary, node.x + 8, node.y + 26, SUBTITLE, false); + } + + text.end(); + } + + private void box(Renderer2D r, double x, double y, double w, double h, Color bg, Color ol) { + r.quad(x + 1, y + 1, w - 2, h - 2, bg); + r.quad(x, y, w, 1, ol); + r.quad(x, y + h - 1, w, 1, ol); + r.quad(x, y + 1, 1, h - 2, ol); + r.quad(x + w - 1, y + 1, 1, h - 2, ol); + } + + // Helpers + + private int scale(double coord) { + return (int) (coord * mc.getWindow().getGuiScale()); + } + + private FlowNode getNodeAt(int mx, int my) { + for (int i = flow.nodes.size() - 1; i >= 0; i--) { + FlowNode n = flow.nodes.get(i); + if (mx >= n.x && mx <= n.x + NODE_W && my >= n.y && my <= n.y + NODE_H) return n; + } + return null; + } + + private FlowNode getOutputPortAt(int mx, int my) { + for (int i = flow.nodes.size() - 1; i >= 0; i--) { + FlowNode n = flow.nodes.get(i); + double px = n.x + NODE_W, py = n.y + NODE_H / 2.0; + if (Math.abs(mx - px) <= PORT && Math.abs(my - py) <= PORT) return n; + } + return null; + } + + private FlowNode currentNode() { + FlowBuilder module = Modules.get().get(FlowBuilder.class); + if (module == null || module.getRunningFlow() != flow) return null; + return module.getCurrentNode(); + } + + private void save() { + BaritoneFlows.get().save(); + } + + private static String clip(String text, int max) { + if (text == null) return ""; + return text.length() <= max ? text : text.substring(0, max - 1) + "…"; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowNodeScreen.java b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowNodeScreen.java new file mode 100644 index 00000000000..58e78105cf0 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowNodeScreen.java @@ -0,0 +1,58 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.gui.screens.baritoneflow; + +import meteordevelopment.meteorclient.gui.GuiTheme; +import meteordevelopment.meteorclient.gui.WindowScreen; +import meteordevelopment.meteorclient.gui.widgets.containers.WContainer; +import meteordevelopment.meteorclient.gui.widgets.pressable.WButton; +import meteordevelopment.meteorclient.systems.baritoneflow.BaritoneFlows; +import meteordevelopment.meteorclient.systems.baritoneflow.Flow; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNode; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNodeType; + +/** + * Edits a single {@link FlowNode}'s parameters (rendered from its {@link FlowNode#settings}) and + * lets you delete it. Changes are saved when the screen closes. + */ +public class FlowNodeScreen extends WindowScreen { + private final Flow flow; + private final FlowNode node; + private WContainer settingsContainer; + + public FlowNodeScreen(GuiTheme theme, Flow flow, FlowNode node) { + super(theme, "Edit Node"); + + this.flow = flow; + this.node = node; + } + + @Override + public void initWidgets() { + settingsContainer = add(theme.verticalList()).expandX().widget(); + settingsContainer.add(theme.settings(node.settings)).expandX(); + + if (node.type.get() != FlowNodeType.Start) { + add(theme.horizontalSeparator()).expandX(); + + WButton delete = add(theme.button("Delete Node")).expandX().widget(); + delete.action = () -> { + flow.removeNode(node); + onClose(); + }; + } + } + + @Override + public void tick() { + node.settings.tick(settingsContainer, theme); + } + + @Override + protected void onClosed() { + BaritoneFlows.get().save(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowsScreen.java b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowsScreen.java new file mode 100644 index 00000000000..ee3fc4d4070 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/gui/screens/baritoneflow/FlowsScreen.java @@ -0,0 +1,88 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.gui.screens.baritoneflow; + +import meteordevelopment.meteorclient.gui.GuiTheme; +import meteordevelopment.meteorclient.gui.WindowScreen; +import meteordevelopment.meteorclient.gui.widgets.containers.WHorizontalList; +import meteordevelopment.meteorclient.gui.widgets.input.WTextBox; +import meteordevelopment.meteorclient.gui.widgets.pressable.WButton; +import meteordevelopment.meteorclient.systems.baritoneflow.BaritoneFlows; +import meteordevelopment.meteorclient.systems.baritoneflow.Flow; +import meteordevelopment.meteorclient.systems.modules.Modules; +import meteordevelopment.meteorclient.systems.modules.baritone.FlowBuilder; + +import static meteordevelopment.meteorclient.MeteorClient.mc; + +/** + * Lists all saved {@link Flow}s and lets you create, open (in the editor), run and delete them. + */ +public class FlowsScreen extends WindowScreen { + private WTextBox nameBox; + + public FlowsScreen(GuiTheme theme) { + super(theme, "Baritone Flows"); + } + + @Override + public void initWidgets() { + // Create new flow + WHorizontalList create = add(theme.horizontalList()).expandX().widget(); + nameBox = create.add(theme.textBox("")).expandX().widget(); + nameBox.setFocused(true); + + WButton createBtn = create.add(theme.button("Create")).widget(); + createBtn.action = this::createFlow; + enterAction = createBtn.action; + + add(theme.horizontalSeparator()).expandX(); + + BaritoneFlows flows = BaritoneFlows.get(); + if (flows.isEmpty()) { + add(theme.label("No saved flows yet. Type a name above and click Create.")); + return; + } + + for (Flow flow : flows) { + WHorizontalList row = add(theme.horizontalList()).expandX().widget(); + + row.add(theme.label(flow.name)).expandCellX().widget(); + + WButton armed = row.add(theme.button(flow.armed ? "Armed" : "Disarmed")).widget(); + armed.tooltip = "While armed, this flow's trigger nodes are watched in the background and can start it on their own."; + armed.action = () -> { + flow.armed = !flow.armed; + flows.save(); + reload(); + }; + + WButton edit = row.add(theme.button("Edit")).widget(); + edit.action = () -> mc.setScreen(new FlowEditorScreen(theme, flow)); + + WButton run = row.add(theme.button("Run")).widget(); + run.action = () -> { + FlowBuilder module = Modules.get().get(FlowBuilder.class); + if (module != null) module.runFlow(flow); + onClose(); + }; + + WButton delete = row.add(theme.button("Delete")).widget(); + delete.action = () -> { + flows.remove(flow); + reload(); + }; + } + } + + private void createFlow() { + String name = nameBox.get().trim(); + if (name.isEmpty() || BaritoneFlows.get().get(name) != null) return; + + Flow flow = new Flow(name); + BaritoneFlows.get().add(flow); + mc.setScreen(new FlowEditorScreen(theme, flow)); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/mixin/ClientCommonPacketListenerMixin.java b/src/main/java/meteordevelopment/meteorclient/mixin/ClientCommonPacketListenerMixin.java new file mode 100644 index 00000000000..6a9a1650140 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/mixin/ClientCommonPacketListenerMixin.java @@ -0,0 +1,23 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.mixin; + +import meteordevelopment.meteorclient.MeteorClient; +import meteordevelopment.meteorclient.events.game.DisconnectReasonEvent; +import net.minecraft.client.multiplayer.ClientCommonPacketListenerImpl; +import net.minecraft.network.DisconnectionDetails; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ClientCommonPacketListenerImpl.class) +public abstract class ClientCommonPacketListenerMixin { + @Inject(method = "onDisconnect", at = @At("HEAD")) + private void onDisconnect(DisconnectionDetails details, CallbackInfo ci) { + MeteorClient.EVENT_BUS.post(new DisconnectReasonEvent(details.reason().getString())); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/pathing/BaritoneUtils.java b/src/main/java/meteordevelopment/meteorclient/pathing/BaritoneUtils.java index b270b2392ff..ac865aa86a5 100644 --- a/src/main/java/meteordevelopment/meteorclient/pathing/BaritoneUtils.java +++ b/src/main/java/meteordevelopment/meteorclient/pathing/BaritoneUtils.java @@ -20,4 +20,10 @@ public static String getPrefix() { return ""; } + + public static void runCommand(String command) { + if (!IS_AVAILABLE || command == null || command.isBlank()) return; + + BaritoneAPI.getProvider().getPrimaryBaritone().getCommandManager().execute(command); + } } diff --git a/src/main/java/meteordevelopment/meteorclient/systems/Systems.java b/src/main/java/meteordevelopment/meteorclient/systems/Systems.java index 803d5706b84..7db86aef095 100644 --- a/src/main/java/meteordevelopment/meteorclient/systems/Systems.java +++ b/src/main/java/meteordevelopment/meteorclient/systems/Systems.java @@ -9,6 +9,8 @@ import meteordevelopment.meteorclient.MeteorClient; import meteordevelopment.meteorclient.events.game.GameLeftEvent; import meteordevelopment.meteorclient.systems.accounts.Accounts; +import meteordevelopment.meteorclient.systems.baritoneflow.BaritoneFlows; +import meteordevelopment.meteorclient.systems.baritoneflow.DisconnectLog; import meteordevelopment.meteorclient.systems.config.Config; import meteordevelopment.meteorclient.systems.friends.Friends; import meteordevelopment.meteorclient.systems.hud.Hud; @@ -46,6 +48,8 @@ public static void init() { config.settings.registerColorSettings(null); add(new Macros()); + add(new BaritoneFlows()); + add(new DisconnectLog()); add(new Friends()); add(new Accounts()); add(new Waypoints()); diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/BaritoneFlows.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/BaritoneFlows.java new file mode 100644 index 00000000000..7883e6a482b --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/BaritoneFlows.java @@ -0,0 +1,73 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +import meteordevelopment.meteorclient.systems.System; +import meteordevelopment.meteorclient.systems.Systems; +import meteordevelopment.meteorclient.utils.misc.NbtUtils; +import net.minecraft.nbt.CompoundTag; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Persists all saved {@link Flow}s to disk so they can be reopened and run across sessions. + */ +public class BaritoneFlows extends System implements Iterable { + private List flows = new ArrayList<>(); + + public BaritoneFlows() { + super("baritone-flows"); + } + + public static BaritoneFlows get() { + return Systems.get(BaritoneFlows.class); + } + + public void add(Flow flow) { + flows.add(flow); + save(); + } + + public void remove(Flow flow) { + if (flows.remove(flow)) save(); + } + + public Flow get(String name) { + for (Flow flow : flows) { + if (flow.name.equalsIgnoreCase(name)) return flow; + } + return null; + } + + public List getAll() { + return flows; + } + + public boolean isEmpty() { + return flows.isEmpty(); + } + + @Override + public @NotNull Iterator iterator() { + return flows.iterator(); + } + + @Override + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + tag.put("flows", NbtUtils.listToTag(flows)); + return tag; + } + + @Override + public BaritoneFlows fromTag(CompoundTag tag) { + flows = NbtUtils.listFromTag(tag.getListOrEmpty("flows"), Flow::new); + return this; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/DisconnectLog.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/DisconnectLog.java new file mode 100644 index 00000000000..3ff89d88b40 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/DisconnectLog.java @@ -0,0 +1,83 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +import meteordevelopment.meteorclient.events.game.DisconnectReasonEvent; +import meteordevelopment.meteorclient.systems.System; +import meteordevelopment.meteorclient.systems.Systems; +import meteordevelopment.orbit.EventHandler; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Keeps a small history of every disconnect (kicks, timeouts, our own Leave nodes, ...) with its + * reason, so it can be reviewed later regardless of whether any module was active at the time. + */ +public class DisconnectLog extends System { + private static final int MAX_ENTRIES = 100; + + private final List entries = new ArrayList<>(); + + public DisconnectLog() { + super("disconnect-log"); + } + + public static DisconnectLog get() { + return Systems.get(DisconnectLog.class); + } + + @EventHandler + private void onDisconnectReason(DisconnectReasonEvent event) { + entries.addFirst(new Entry(event.reason, java.lang.System.currentTimeMillis())); + while (entries.size() > MAX_ENTRIES) entries.removeLast(); + + save(); + } + + public List getAll() { + return Collections.unmodifiableList(entries); + } + + public void clear() { + entries.clear(); + save(); + } + + @Override + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + + ListTag list = new ListTag(); + for (Entry entry : entries) { + CompoundTag entryTag = new CompoundTag(); + entryTag.putString("reason", entry.reason()); + entryTag.putLong("time", entry.time()); + list.add(entryTag); + } + tag.put("entries", list); + + return tag; + } + + @Override + public DisconnectLog fromTag(CompoundTag tag) { + entries.clear(); + + for (Tag t : tag.getListOrEmpty("entries")) { + CompoundTag entryTag = (CompoundTag) t; + entries.add(new Entry(entryTag.getStringOr("reason", ""), entryTag.getLongOr("time", 0))); + } + + return this; + } + + public record Entry(String reason, long time) {} +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/Flow.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/Flow.java new file mode 100644 index 00000000000..40cdc3d6757 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/Flow.java @@ -0,0 +1,144 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +import meteordevelopment.meteorclient.utils.misc.ISerializable; +import meteordevelopment.meteorclient.utils.misc.NbtUtils; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * A named, saveable graph of {@link FlowNode}s. Nodes are connected via their child ids; execution + * order is a depth-first pre-order traversal from the {@link FlowNodeType#Start} node. + */ +public class Flow implements ISerializable { + public String name; + /** While armed, this flow's trigger nodes (e.g. {@link FlowNodeType#OnPlayerNearAppear}) are watched in the background and can start it on their own. */ + public boolean armed; + public final List nodes = new ArrayList<>(); + private int nextId = 1; + + public Flow(String name) { + this.name = name; + + // Every flow starts with a Start node as its entry point. + FlowNode start = addNode(40, 60); + start.type.set(FlowNodeType.Start); + } + + public Flow(Tag tag) { + fromTag((CompoundTag) tag); + } + + public FlowNode addNode(int x, int y) { + FlowNode node = new FlowNode(nextId++, x, y); + nodes.add(node); + return node; + } + + public void removeNode(FlowNode node) { + if (node.type.get() == FlowNodeType.Start) return; // never delete the entry point + + nodes.remove(node); + for (FlowNode n : nodes) n.children.removeIf(id -> id == node.id); + } + + public FlowNode getById(int id) { + for (FlowNode n : nodes) { + if (n.id == id) return n; + } + return null; + } + + public FlowNode getStart() { + for (FlowNode n : nodes) { + if (n.type.get() == FlowNodeType.Start) return n; + } + return nodes.isEmpty() ? null : nodes.getFirst(); + } + + public List getNodesOfType(FlowNodeType type) { + List result = new ArrayList<>(); + for (FlowNode n : nodes) { + if (n.type.get() == type) result.add(n); + } + return result; + } + + public void connect(FlowNode from, FlowNode to) { + if (from == to) return; + if (!from.children.contains(to.id)) from.children.add(to.id); + } + + public void disconnect(FlowNode from, FlowNode to) { + from.children.removeIf(id -> id == to.id); + } + + /** Depth-first pre-order traversal from the Start node. See {@link #computeOrder(FlowNode)}. */ + public List computeOrder() { + FlowNode start = getStart(); + return start == null ? new ArrayList<>() : computeOrder(start); + } + + /** + * Depth-first pre-order traversal starting from the given node (used both for a manual run from + * Start and for a trigger node firing on its own). Already visited nodes are skipped, so loops + * in the graph terminate instead of running forever. + */ + public List computeOrder(FlowNode entry) { + List order = new ArrayList<>(); + if (entry == null) return order; + + Set visited = new HashSet<>(); + Deque stack = new ArrayDeque<>(); + stack.push(entry); + + while (!stack.isEmpty()) { + FlowNode node = stack.pop(); + if (!visited.add(node.id)) continue; + order.add(node); + + // Push children in reverse so the first child is processed first. + for (int i = node.children.size() - 1; i >= 0; i--) { + FlowNode child = getById(node.children.get(i)); + if (child != null && !visited.contains(child.id)) stack.push(child); + } + } + + return order; + } + + @Override + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + + tag.putString("name", name); + tag.putBoolean("armed", armed); + tag.putInt("nextId", nextId); + tag.put("nodes", NbtUtils.listToTag(nodes)); + + return tag; + } + + @Override + public Flow fromTag(CompoundTag tag) { + name = tag.getStringOr("name", "Flow"); + armed = tag.getBooleanOr("armed", false); + nextId = tag.getIntOr("nextId", 1); + + nodes.clear(); + nodes.addAll(NbtUtils.listFromTag(tag.getListOrEmpty("nodes"), FlowNode::new)); + + return this; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowNode.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowNode.java new file mode 100644 index 00000000000..1a3c2dfe5c0 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowNode.java @@ -0,0 +1,330 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +import meteordevelopment.meteorclient.settings.*; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.utils.misc.ISerializable; +import net.minecraft.core.BlockPos; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.level.block.Block; + +import java.util.ArrayList; +import java.util.List; + +/** + * A single step in a {@link Flow}. Holds its own {@link Settings} (so parameters serialize and + * render for free), a position on the editor canvas, and the ids of the nodes it points to. + */ +public class FlowNode implements ISerializable { + public final Settings settings = new Settings(); + private final SettingGroup sg = settings.getDefaultGroup(); + + public final Setting type = sg.add(new EnumSetting.Builder() + .name("type") + .description("What this node makes Baritone do.") + .defaultValue(FlowNodeType.Goto) + .build() + ); + + public final Setting label = sg.add(new StringSetting.Builder() + .name("label") + .description("Optional custom label shown on the node.") + .defaultValue("") + .build() + ); + + // Goto + + public final Setting target = sg.add(new BlockPosSetting.Builder() + .name("target") + .description("Destination to path to.") + .visible(() -> type.get() == FlowNodeType.Goto) + .build() + ); + + public final Setting ignoreY = sg.add(new BoolSetting.Builder() + .name("ignore-y") + .description("Reach the X and Z of the target, ignoring its Y.") + .defaultValue(false) + .visible(() -> type.get() == FlowNodeType.Goto) + .build() + ); + + // Mine + + public final Setting> blocks = sg.add(new BlockListSetting.Builder() + .name("blocks") + .description("Blocks to mine.") + .visible(() -> type.get() == FlowNodeType.Mine) + .build() + ); + + // Follow + + public final Setting followTarget = sg.add(new EnumSetting.Builder() + .name("follow-target") + .description("Who to follow.") + .defaultValue(FollowTarget.NearestPlayer) + .visible(() -> type.get() == FlowNodeType.Follow) + .build() + ); + + public final Setting followName = sg.add(new StringSetting.Builder() + .name("player-name") + .description("Name of the player to follow.") + .defaultValue("") + .visible(() -> type.get() == FlowNodeType.Follow && followTarget.get() == FollowTarget.PlayerByName) + .build() + ); + + // Wait + + public final Setting seconds = sg.add(new DoubleSetting.Builder() + .name("seconds") + .description("For Wait: how long to pause (e.g. 1800 for 30 minutes). For Follow: how long to follow before continuing (0 keeps following in the background).") + .defaultValue(3) + .min(0) + .sliderRange(0, 1800) + .visible(() -> type.get() == FlowNodeType.Wait || type.get() == FlowNodeType.Follow) + .build() + ); + + // Command + + public final Setting command = sg.add(new StringSetting.Builder() + .name("command") + .description("Raw Baritone command without the prefix, e.g. \"farm 30\" or \"build house\".") + .defaultValue("") + .visible(() -> type.get() == FlowNodeType.Command) + .build() + ); + + // Max duration for long-running Baritone tasks (Goto/Mine/Command) + + public final Setting maxDuration = sg.add(new DoubleSetting.Builder() + .name("max-duration") + .description("Give up on this task after this many seconds and move on. 0 = run until it finishes (needed for mining all night).") + .defaultValue(0) + .min(0) + .sliderRange(0, 3600) + .visible(() -> type.get() == FlowNodeType.Goto || type.get() == FlowNodeType.Mine || type.get() == FlowNodeType.Command) + .build() + ); + + // Notify + + public final Setting notifyMessage = sg.add(new StringSetting.Builder() + .name("message") + .description("Message to print to chat when this node runs (handy for logging what happened overnight).") + .defaultValue("") + .visible(() -> type.get() == FlowNodeType.Notify) + .build() + ); + + // Module (any Meteor Client module, not just Baritone) + + public final Setting moduleAction = sg.add(new EnumSetting.Builder() + .name("module-action") + .description("What to do with the target module.") + .defaultValue(ModuleAction.Toggle) + .visible(() -> type.get() == FlowNodeType.Module) + .build() + ); + + public final Setting> targetModule = sg.add(new ModuleListSetting.Builder() + .name("module") + .description("The module to control. Only the first selected module is used.") + .visible(() -> type.get() == FlowNodeType.Module) + .build() + ); + + public final Setting moduleSettingName = sg.add(new StringSetting.Builder() + .name("setting-name") + .description("Name of the setting to change, e.g. \"target-block\" or \"delay\".") + .defaultValue("") + .visible(() -> type.get() == FlowNodeType.Module && moduleAction.get() == ModuleAction.SetSetting) + .build() + ); + + public final Setting moduleSettingValue = sg.add(new StringSetting.Builder() + .name("setting-value") + .description("New value for the setting, in the same text format you'd use in chat/commands.") + .defaultValue("") + .visible(() -> type.get() == FlowNodeType.Module && moduleAction.get() == ModuleAction.SetSetting) + .build() + ); + + // Triggers + + public final Setting triggerRange = sg.add(new IntSetting.Builder() + .name("range") + .description("How close a player / hostile mob has to be to fire this trigger.") + .defaultValue(32) + .min(1) + .sliderMax(64) + .visible(() -> type.get() == FlowNodeType.OnPlayerNearAppear || type.get() == FlowNodeType.OnHostileMobsNear) + .build() + ); + + public final Setting ignoreFriends = sg.add(new BoolSetting.Builder() + .name("ignore-friends") + .description("Don't fire when the player who appeared is on your friends list.") + .defaultValue(true) + .visible(() -> type.get() == FlowNodeType.OnPlayerNearAppear) + .build() + ); + + public final Setting mobCount = sg.add(new IntSetting.Builder() + .name("mob-count") + .description("Fire once at least this many hostile mobs are within range.") + .defaultValue(1) + .min(1) + .sliderRange(1, 30) + .visible(() -> type.get() == FlowNodeType.OnHostileMobsNear) + .build() + ); + + public final Setting hungerThreshold = sg.add(new IntSetting.Builder() + .name("hunger-threshold") + .description("Fire when your food level drops to or below this (0-20).") + .defaultValue(6) + .range(0, 20) + .sliderRange(0, 20) + .visible(() -> type.get() == FlowNodeType.OnHunger) + .build() + ); + + public final Setting healthThreshold = sg.add(new DoubleSetting.Builder() + .name("health-threshold") + .description("Fire when your health drops to or below this (0-20).") + .defaultValue(8) + .range(0, 20) + .sliderRange(0, 20) + .visible(() -> type.get() == FlowNodeType.OnLowHealth) + .build() + ); + + public final Setting freeSlots = sg.add(new IntSetting.Builder() + .name("free-slots") + .description("Fire when the number of free inventory slots drops to or below this. 0 = fire only when completely full.") + .defaultValue(0) + .min(0) + .sliderRange(0, 36) + .visible(() -> type.get() == FlowNodeType.OnInventoryFull) + .build() + ); + + public final Setting disconnectReasonFilter = sg.add(new StringSetting.Builder() + .name("reason-contains") + .description("Only fire when the disconnect reason contains this text (case-insensitive). Leave empty to fire on any disconnect.") + .defaultValue("") + .visible(() -> type.get() == FlowNodeType.OnDisconnect) + .build() + ); + + public int id; + public int x, y; // Position on the editor canvas + public final List children = new ArrayList<>(); + + public FlowNode(int id, int x, int y) { + this.id = id; + this.x = x; + this.y = y; + } + + public FlowNode(Tag tag) { + fromTag((CompoundTag) tag); + } + + /** Short text shown in the node header on the canvas. */ + public String title() { + String custom = label.get(); + if (custom != null && !custom.isBlank()) return custom; + return type.get().label(); + } + + /** One-line summary of the node's key parameter, shown in the node body. */ + public String summary() { + return switch (type.get()) { + case Goto -> { + BlockPos p = target.get(); + yield p.getX() + ", " + (ignoreY.get() ? "~" : p.getY()) + ", " + p.getZ(); + } + case Mine -> blocks.get().isEmpty() ? "(no blocks)" : blocks.get().size() + " block(s)"; + case Follow -> followTarget.get() == FollowTarget.PlayerByName ? followName.get() : followTarget.get().toString(); + case Wait -> seconds.get() + "s"; + case Command -> command.get().isBlank() ? "(empty)" : command.get(); + case Module -> moduleSummary(); + case Notify -> notifyMessage.get().isBlank() ? "(no message)" : notifyMessage.get(); + case OnPlayerNearAppear -> "range " + triggerRange.get() + (ignoreFriends.get() ? ", friends ignored" : ""); + case OnHostileMobsNear -> ">=" + mobCount.get() + " within " + triggerRange.get(); + case OnHunger -> "food <= " + hungerThreshold.get(); + case OnLowHealth -> "health <= " + healthThreshold.get(); + case OnInventoryFull -> freeSlots.get() == 0 ? "completely full" : "<= " + freeSlots.get() + " free slots"; + case OnDisconnect -> disconnectReasonFilter.get().isBlank() ? "any reason" : "reason contains \"" + disconnectReasonFilter.get() + "\""; + case Leave -> "Disconnects from the server"; + case Reconnect -> "Reconnects to the last server"; + default -> ""; + }; + } + + private String moduleSummary() { + String modName = targetModule.get().isEmpty() ? "(no module)" : targetModule.get().getFirst().title; + + return switch (moduleAction.get()) { + case Enable -> "Enable " + modName; + case Disable -> "Disable " + modName; + case Toggle -> "Toggle " + modName; + case SetSetting -> modName + "." + moduleSettingName.get() + " = " + moduleSettingValue.get(); + }; + } + + @Override + public CompoundTag toTag() { + CompoundTag tag = new CompoundTag(); + + tag.putInt("id", id); + tag.putInt("x", x); + tag.putInt("y", y); + + int[] c = new int[children.size()]; + for (int i = 0; i < c.length; i++) c[i] = children.get(i); + tag.putIntArray("children", c); + + tag.put("settings", settings.toTag()); + + return tag; + } + + @Override + public FlowNode fromTag(CompoundTag tag) { + id = tag.getIntOr("id", 0); + x = tag.getIntOr("x", 0); + y = tag.getIntOr("y", 0); + + children.clear(); + for (int v : tag.getIntArray("children").orElse(new int[0])) children.add(v); + + if (tag.contains("settings")) settings.fromTag(tag.getCompoundOrEmpty("settings")); + + return this; + } + + public enum FollowTarget { + NearestPlayer, + NearestEntity, + PlayerByName + } + + public enum ModuleAction { + Enable, + Disable, + Toggle, + SetSetting + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowNodeType.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowNodeType.java new file mode 100644 index 00000000000..6a005d06536 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowNodeType.java @@ -0,0 +1,65 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +/** + * The kind of step a {@link FlowNode} represents: a Baritone task, a Meteor module action, a + * client-level action, or an event trigger that starts a flow on its own. + */ +public enum FlowNodeType { + Start, + Goto, + Mine, + Follow, + Wait, + Pause, + Resume, + Stop, + Command, + Module, + Notify, + Leave, + Reconnect, + OnPlayerNearAppear, + OnHunger, + OnHostileMobsNear, + OnLowHealth, + OnInventoryFull, + OnDisconnect; + + public boolean isTrigger() { + return category() == Category.Trigger; + } + + public Category category() { + return switch (this) { + case OnPlayerNearAppear, OnHunger, OnHostileMobsNear, OnLowHealth, OnInventoryFull, OnDisconnect -> Category.Trigger; + case Module -> Category.Module; + case Notify, Leave, Reconnect -> Category.Client; + default -> Category.Baritone; + }; + } + + /** Nicer display text than {@link #toString()} for node types whose name doesn't read well on its own. */ + public String label() { + return switch (this) { + case OnPlayerNearAppear -> "Player Near"; + case OnHunger -> "On Hunger"; + case OnHostileMobsNear -> "Hostile Mobs Near"; + case OnLowHealth -> "On Low Health"; + case OnInventoryFull -> "Inventory Full"; + case OnDisconnect -> "On Disconnect"; + default -> toString(); + }; + } + + public enum Category { + Trigger, + Baritone, + Module, + Client + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowRunner.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowRunner.java new file mode 100644 index 00000000000..0789b5834ba --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowRunner.java @@ -0,0 +1,392 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +import meteordevelopment.meteorclient.pathing.BaritoneUtils; +import meteordevelopment.meteorclient.pathing.IPathManager; +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.systems.modules.Modules; +import meteordevelopment.meteorclient.systems.modules.misc.AutoReconnect; +import meteordevelopment.meteorclient.utils.player.ChatUtils; +import net.minecraft.client.gui.screens.ConnectScreen; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.common.ClientboundDisconnectPacket; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.Block; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.function.Predicate; + +import static meteordevelopment.meteorclient.MeteorClient.mc; + +/** + * Runs a {@link Flow} as a queue of tasks, one at a time, driving Baritone through + * {@link PathManagers}. Nodes are pulled off the front of the queue; each one either finishes + * instantly, waits for Baritone to stop pathing, or waits a fixed duration before the next is + * pulled. This makes big sequential flows (goto -> mine -> leave) run reliably for a long time. + * + *

Trigger nodes fire via {@link #interrupt(Flow, FlowNode)}: their branch is pushed to the front + * of the queue and the currently running, resumable task (goto/mine/follow/command) is stopped and + * re-queued so it resumes once the handler finishes.

+ */ +public class FlowRunner { + private static final double PATH_FACTOR = 1.3; // real Baritone paths aren't straight lines, so pad the estimate + + private final Deque queue = new ArrayDeque<>(); + + private Flow flow; + private FlowNode current; + private boolean running; + private boolean paused; + + private String lastDisconnectReason = ""; + + private State state; + private long nodeStartTime; + private int startupTicks; + + // ETA tracking for the current Goto target. + private BlockPos currentTarget; + private double lastX, lastZ; + private boolean hasLastPos; + private double smoothedSpeed; // horizontal blocks per second, smoothed - captures the player's real pace/gear + + private enum State { + Begin, + WaitPathing, + WaitTime, + Instant + } + + public boolean isRunning() { + return running; + } + + public Flow getFlow() { + return flow; + } + + public FlowNode getCurrentNode() { + return running ? current : null; + } + + /** Runs the flow from its Start node. */ + public void start(Flow flow) { + stop(); + + this.flow = flow; + queue.addAll(flow.computeOrder()); + running = !queue.isEmpty(); + } + + /** + * Injects a trigger node's branch at the front of the queue as an interrupt. The currently + * running resumable task is stopped and re-queued behind the handler so it resumes afterwards. + */ + public void interrupt(Flow flow, FlowNode triggerNode) { + List branch = flow.computeOrder(triggerNode); + if (branch.isEmpty()) return; + + this.flow = flow; + + // Stop and re-queue the current resumable task so it resumes after the handler runs. + if (current != null && isResumable(current)) { + if (BaritoneUtils.IS_AVAILABLE) PathManagers.get().stop(); + queue.addFirst(current); + } + + // Push the handler branch to the front (reverse order so its first node runs first). + for (int i = branch.size() - 1; i >= 0; i--) queue.addFirst(branch.get(i)); + + current = null; // force the next tick to pull the handler + running = true; + } + + public void stop() { + if (running && BaritoneUtils.IS_AVAILABLE) PathManagers.get().stop(); + + running = false; + paused = false; + flow = null; + current = null; + currentTarget = null; + queue.clear(); + } + + /** + * Pauses the whole flow - not just Baritone's current path - so {@link #tick()} stops pulling + * the next queued node while paused. Without this, pausing Baritone mid-Goto makes + * {@code pm.isPathing()} report false, which the queue would otherwise read as "task finished" + * and immediately advance to whatever comes next (e.g. a Leave node disconnecting the player). + */ + public void pause() { + if (!running) return; + + paused = true; + if (BaritoneUtils.IS_AVAILABLE) PathManagers.get().pause(); + } + + public void resume() { + if (!running) return; + + paused = false; + if (BaritoneUtils.IS_AVAILABLE) PathManagers.get().resume(); + } + + public boolean isPaused() { + return paused; + } + + public String getLastDisconnectReason() { + return lastDisconnectReason; + } + + public void setLastDisconnectReason(String reason) { + lastDisconnectReason = reason == null ? "" : reason; + } + + /** + * Estimated time of arrival for the current Goto target, or null when there's nothing to path to. + * Uses the straight-line distance, a smoothed measurement of the player's real speed (which + * captures their gear/experience), and a path-inefficiency factor. + */ + public String getEtaText() { + if (!running || currentTarget == null || mc.player == null) return null; + + double dx = currentTarget.getX() + 0.5 - mc.player.getX(); + double dz = currentTarget.getZ() + 0.5 - mc.player.getZ(); + double distance = Math.sqrt(dx * dx + dz * dz); + + if (distance < 2) return null; // basically arrived + + if (smoothedSpeed < 0.5) return "ETA calculating... " + Math.round(distance) + "m"; + + double etaSeconds = distance * PATH_FACTOR / smoothedSpeed; + return "ETA " + formatTime(etaSeconds) + " (" + Math.round(distance) + "m @ " + String.format("%.1f", smoothedSpeed) + " b/s)"; + } + + private void updateSpeed() { + if (mc.player == null) { + hasLastPos = false; + return; + } + + double x = mc.player.getX(); + double z = mc.player.getZ(); + + if (hasLastPos) { + double dx = x - lastX; + double dz = z - lastZ; + double instant = Math.sqrt(dx * dx + dz * dz) * 20.0; // blocks/tick -> blocks/second + smoothedSpeed = smoothedSpeed * 0.9 + instant * 0.1; + } + + lastX = x; + lastZ = z; + hasLastPos = true; + } + + private static String formatTime(double seconds) { + long s = Math.round(seconds); + if (s >= 3600) return (s / 3600) + "h " + ((s % 3600) / 60) + "m"; + if (s >= 60) return (s / 60) + "m " + (s % 60) + "s"; + return s + "s"; + } + + public void tick() { + if (!running) return; + + updateSpeed(); + if (paused) return; + + if (current == null) { + current = queue.pollFirst(); + if (current == null) { + finish(); + return; + } + state = State.Begin; + } + + IPathManager pm = PathManagers.get(); + + switch (state) { + case Begin -> begin(current, pm); + case Instant -> complete(); + case WaitTime -> { + if (System.currentTimeMillis() - nodeStartTime >= current.seconds.get() * 1000) complete(); + } + case WaitPathing -> { + double max = current.maxDuration.get(); + boolean timedOut = max > 0 && System.currentTimeMillis() - nodeStartTime > max * 1000; + + if (startupTicks > 0) startupTicks--; // give Baritone a moment to actually start pathing + else if (!pm.isPathing() || timedOut) complete(); + } + } + } + + private void begin(FlowNode node, IPathManager pm) { + // Baritone/world actions do nothing while disconnected - skip them so the queue keeps + // draining (important for Leave -> Wait -> Reconnect and mid-flow reconnects). + boolean offline = mc.player == null; + + currentTarget = null; // only the currently running Goto has an ETA target + + switch (node.type.get()) { + // Entry-point / marker nodes just pass through to their children. + case Start, OnPlayerNearAppear, OnHunger, OnHostileMobsNear, OnLowHealth, OnInventoryFull -> state = State.Instant; + + case Pause -> { + if (BaritoneUtils.IS_AVAILABLE) pm.pause(); + state = State.Instant; + } + case Resume -> { + if (BaritoneUtils.IS_AVAILABLE) pm.resume(); + state = State.Instant; + } + case Stop -> { + pm.stop(); + finish(); + } + case Goto -> { + if (offline) state = State.Instant; + else { + currentTarget = node.target.get(); + pm.moveTo(node.target.get(), node.ignoreY.get()); + beginPathing(); + } + } + case Mine -> { + if (offline || node.blocks.get().isEmpty()) state = State.Instant; + else { + pm.mine(node.blocks.get().toArray(new Block[0])); + beginPathing(); + } + } + case Follow -> { + if (offline) state = State.Instant; + else { + pm.follow(followPredicate(node)); + beginWait(); // follow is continuous, so keep it running for the node's duration then move on + } + } + case Wait -> beginWait(); + case Command -> { + if (offline) state = State.Instant; + else { + BaritoneUtils.runCommand(node.command.get()); + beginPathing(); // most commands (goto/mine/farm/build) put Baritone into a pathing state + } + } + case Module -> { + executeModuleAction(node); + state = State.Instant; + } + case Notify -> { + runNotify(node); + state = State.Instant; + } + case Leave -> { + leave(); + state = State.Instant; + } + case Reconnect -> { + reconnect(); + state = State.Instant; + } + } + } + + private boolean isResumable(FlowNode node) { + return switch (node.type.get()) { + case Goto, Mine, Follow, Command -> true; + default -> false; + }; + } + + private void executeModuleAction(FlowNode node) { + List modules = node.targetModule.get(); + if (modules.isEmpty()) return; + + Module module = modules.getFirst(); + + switch (node.moduleAction.get()) { + case Enable -> module.enable(); + case Disable -> module.disable(); + case Toggle -> module.toggle(); + case SetSetting -> { + Setting setting = module.settings.get(node.moduleSettingName.get()); + if (setting != null) setting.parse(node.moduleSettingValue.get()); + } + } + } + + private void runNotify(FlowNode node) { + String message = node.notifyMessage.get(); + if (message == null || message.isBlank()) message = "Flow notification"; + message = message.replace("%reason%", lastDisconnectReason); + + ChatUtils.info("[Baritone Flow] %s", message); + } + + private void leave() { + if (mc.player == null || mc.player.connection == null) return; + + mc.player.connection.handleDisconnect(new ClientboundDisconnectPacket(Component.literal("Baritone Flow: Leave node"))); + } + + private void reconnect() { + AutoReconnect autoReconnect = Modules.get().get(AutoReconnect.class); + if (autoReconnect == null || autoReconnect.lastServerConnection == null) return; + + var lastServer = autoReconnect.lastServerConnection; + ConnectScreen.startConnecting(new TitleScreen(), mc, lastServer.left(), lastServer.right(), false, null); + } + + private void beginPathing() { + startupTicks = 5; + nodeStartTime = System.currentTimeMillis(); + state = State.WaitPathing; + } + + private void beginWait() { + nodeStartTime = System.currentTimeMillis(); + state = State.WaitTime; + } + + /** Marks the current node done; the next tick pulls the next node from the queue. */ + private void complete() { + current = null; + } + + private void finish() { + running = false; + paused = false; + flow = null; + current = null; + currentTarget = null; + queue.clear(); + } + + private Predicate followPredicate(FlowNode node) { + return switch (node.followTarget.get()) { + case NearestPlayer -> entity -> entity instanceof Player && entity != mc.player; + case NearestEntity -> entity -> entity != mc.player && entity.isAlive(); + case PlayerByName -> { + String name = node.followName.get(); + yield entity -> entity instanceof Player && entity.getName().getString().equalsIgnoreCase(name); + } + }; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowTriggers.java b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowTriggers.java new file mode 100644 index 00000000000..05f14c99b9f --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/baritoneflow/FlowTriggers.java @@ -0,0 +1,136 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.baritoneflow; + +import meteordevelopment.meteorclient.systems.friends.Friends; +import meteordevelopment.meteorclient.utils.player.PlayerUtils; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.MobCategory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; + +import static meteordevelopment.meteorclient.MeteorClient.mc; + +/** + * Watches every armed {@link Flow}'s trigger nodes in the background and fires a callback the moment + * a trigger's condition newly becomes true, so a flow can start (or interrupt a running flow) on its + * own. Condition-based triggers (hunger, hostile mobs, low health, inventory full) are edge-detected: + * they fire once on the rising edge and won't re-fire until the condition clears and returns. + */ +public class FlowTriggers { + private final Map> nearbyPlayers = new HashMap<>(); + private final Map lastState = new HashMap<>(); + private final BiConsumer onTrigger; + + public FlowTriggers(BiConsumer onTrigger) { + this.onTrigger = onTrigger; + } + + public void tick() { + if (mc.level == null || mc.player == null) return; + + for (Flow flow : BaritoneFlows.get()) { + if (!flow.armed) continue; + + for (FlowNode node : flow.nodes) { + if (!node.type.get().isTrigger()) continue; + + switch (node.type.get()) { + case OnPlayerNearAppear -> checkPlayerNearAppear(flow, node); + case OnHunger -> risingEdge(flow, node, mc.player.getFoodData().getFoodLevel() <= node.hungerThreshold.get()); + case OnHostileMobsNear -> risingEdge(flow, node, countHostiles(node.triggerRange.get()) >= node.mobCount.get()); + case OnLowHealth -> risingEdge(flow, node, mc.player.getHealth() <= node.healthThreshold.get()); + case OnInventoryFull -> risingEdge(flow, node, freeSlots() <= node.freeSlots.get()); + default -> { + } + } + } + } + } + + /** + * Called immediately when the client disconnects, with the human-readable reason text (e.g. a + * kick message). Fires every armed flow's {@link FlowNodeType#OnDisconnect} nodes whose filter + * is empty or matches, unlike the tick-polled triggers above since a disconnect is a one-off event. + */ + public void onDisconnect(String reason) { + String lower = reason == null ? "" : reason.toLowerCase(); + + for (Flow flow : BaritoneFlows.get()) { + if (!flow.armed) continue; + + for (FlowNode node : flow.getNodesOfType(FlowNodeType.OnDisconnect)) { + String filter = node.disconnectReasonFilter.get(); + if (filter == null || filter.isBlank() || lower.contains(filter.toLowerCase())) { + onTrigger.accept(flow, node); + } + } + } + } + + /** Fires once when {@code condition} transitions from false to true. */ + private void risingEdge(Flow flow, FlowNode node, boolean condition) { + boolean was = Boolean.TRUE.equals(lastState.get(node)); + lastState.put(node, condition); + + if (condition && !was) onTrigger.accept(flow, node); + } + + private void checkPlayerNearAppear(Flow flow, FlowNode node) { + Set previous = nearbyPlayers.computeIfAbsent(node, _ -> new HashSet<>()); + Set current = new HashSet<>(); + boolean newAppearance = false; + + for (Entity entity : mc.level.entitiesForRendering()) { + if (!(entity instanceof Player player) || player == mc.player) continue; + if (!PlayerUtils.isWithin(entity, node.triggerRange.get())) continue; + if (node.ignoreFriends.get() && Friends.get().isFriend(player)) continue; + + UUID id = player.getUUID(); + current.add(id); + if (!previous.contains(id)) newAppearance = true; + } + + nearbyPlayers.put(node, current); + + if (newAppearance) onTrigger.accept(flow, node); + } + + private int countHostiles(double range) { + int count = 0; + + for (Entity entity : mc.level.entitiesForRendering()) { + if (entity == mc.player || !entity.isAlive()) continue; + if (entity.getType().getCategory() != MobCategory.MONSTER) continue; + if (PlayerUtils.isWithin(entity, range)) count++; + } + + return count; + } + + private int freeSlots() { + int free = 0; + + for (int i = 0; i < 36; i++) { + ItemStack stack = mc.player.getInventory().getItem(i); + if (stack.isEmpty()) free++; + } + + return free; + } + + public void clear() { + nearbyPlayers.clear(); + lastState.clear(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/Categories.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/Categories.java index 60c50274f59..7d5a1b0ea7b 100644 --- a/src/main/java/meteordevelopment/meteorclient/systems/modules/Categories.java +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/Categories.java @@ -17,6 +17,7 @@ public class Categories { public static final Category Render = new Category("Render", () -> DisplayItemUtils.toStack(Items.GLASS)); public static final Category World = new Category("World", () -> DisplayItemUtils.toStack(Items.GRASS_BLOCK)); public static final Category Misc = new Category("Misc", () -> DisplayItemUtils.toStack(Items.LAVA_BUCKET)); + public static final Category Baritone = new Category("Baritone", () -> DisplayItemUtils.toStack(Items.COMPASS)); public static boolean REGISTERING; @@ -30,6 +31,7 @@ public static void init() { Modules.registerCategory(Render); Modules.registerCategory(World); Modules.registerCategory(Misc); + Modules.registerCategory(Baritone); // Addons AddonManager.ADDONS.forEach(MeteorAddon::onRegisterCategories); diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/Modules.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/Modules.java index e16022894b0..c8a42b539cf 100644 --- a/src/main/java/meteordevelopment/meteorclient/systems/modules/Modules.java +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/Modules.java @@ -20,6 +20,7 @@ import meteordevelopment.meteorclient.systems.System; import meteordevelopment.meteorclient.systems.Systems; import meteordevelopment.meteorclient.systems.config.Config; +import meteordevelopment.meteorclient.systems.modules.baritone.*; import meteordevelopment.meteorclient.systems.modules.combat.*; import meteordevelopment.meteorclient.systems.modules.misc.*; import meteordevelopment.meteorclient.systems.modules.misc.swarm.Swarm; @@ -78,6 +79,7 @@ public void init() { initRender(); initWorld(); initMisc(); + initBaritone(); } @Override @@ -579,4 +581,15 @@ private void initMisc() { add(new Spam()); add(new Swarm()); } + + private void initBaritone() { + add(new Goto()); + add(new Mine()); + add(new Follow()); + add(new Pause()); + add(new Resume()); + add(new Stop()); + add(new Command()); + add(new FlowBuilder()); + } } diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/BaritoneUtil.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/BaritoneUtil.java new file mode 100644 index 00000000000..c360281df11 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/BaritoneUtil.java @@ -0,0 +1,24 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.BaritoneUtils; +import meteordevelopment.meteorclient.systems.modules.Module; + +/** Shared "is Baritone installed" guard for the one-shot modules in this package. */ +final class BaritoneUtil { + private BaritoneUtil() { + } + + static boolean check(Module module) { + if (!BaritoneUtils.IS_AVAILABLE) { + module.error("Baritone is not installed."); + return false; + } + + return true; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Command.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Command.java new file mode 100644 index 00000000000..0f1cb35f6b0 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Command.java @@ -0,0 +1,36 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.BaritoneUtils; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.settings.StringSetting; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; + +/** One-shot command: press its keybind to run a raw Baritone command. */ +public class Command extends Module { + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + + private final Setting command = sgGeneral.add(new StringSetting.Builder() + .name("command") + .description("Raw Baritone command without the prefix, e.g. \"farm 30\" or \"build house\".") + .defaultValue("") + .build() + ); + + public Command() { + super(Categories.Baritone, "command", "Runs a raw Baritone command."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) BaritoneUtils.runCommand(command.get()); + + toggle(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/FlowBuilder.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/FlowBuilder.java new file mode 100644 index 00000000000..4320d3462b5 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/FlowBuilder.java @@ -0,0 +1,182 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.events.game.DisconnectReasonEvent; +import meteordevelopment.meteorclient.events.render.Render2DEvent; +import meteordevelopment.meteorclient.events.world.TickEvent; +import meteordevelopment.meteorclient.gui.GuiTheme; +import meteordevelopment.meteorclient.gui.screens.baritoneflow.DisconnectLogScreen; +import meteordevelopment.meteorclient.gui.screens.baritoneflow.FlowsScreen; +import meteordevelopment.meteorclient.gui.widgets.WWidget; +import meteordevelopment.meteorclient.gui.widgets.containers.WHorizontalList; +import meteordevelopment.meteorclient.gui.widgets.pressable.WButton; +import meteordevelopment.meteorclient.pathing.BaritoneUtils; +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.renderer.Renderer2D; +import meteordevelopment.meteorclient.renderer.text.VanillaTextRenderer; +import meteordevelopment.meteorclient.settings.BoolSetting; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.settings.StringSetting; +import meteordevelopment.meteorclient.systems.baritoneflow.BaritoneFlows; +import meteordevelopment.meteorclient.systems.baritoneflow.Flow; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowNode; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowRunner; +import meteordevelopment.meteorclient.systems.baritoneflow.FlowTriggers; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.utils.Utils; +import meteordevelopment.meteorclient.utils.render.color.Color; +import meteordevelopment.orbit.EventHandler; + +/** + * Visual node-flow editor and runner: build, save and run flows (see {@link FlowsScreen}) that chain + * Baritone tasks, Meteor module actions and client actions together. Flows can also be armed to + * start themselves from a trigger node (e.g. a player appearing nearby) - this module stays + * subscribed across disconnects/reconnects ({@code runInMainMenu}) so a flow like "player appears + * -> leave -> wait -> reconnect" keeps running while logged out. For one-off tasks without a flow, + * see the other modules in the Baritone category (Goto, Mine, Follow, Pause, Resume, Stop, Command). + */ +public class FlowBuilder extends Module { + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + + private final Setting runOnEnable = sgGeneral.add(new StringSetting.Builder() + .name("run-on-enable") + .description("Name of a saved flow to start automatically when this module is enabled. Leave empty to only use the editor.") + .defaultValue("") + .build() + ); + + private final Setting showEta = sgGeneral.add(new BoolSetting.Builder() + .name("show-eta") + .description("Show an estimated time of arrival in the top-right while a flow's Goto task is running.") + .defaultValue(true) + .build() + ); + + private static final Color ETA_BG = new Color(0, 0, 0, 150); + private static final Color ETA_TEXT = new Color(255, 255, 255); + + private final FlowRunner runner = new FlowRunner(); + private final FlowTriggers triggers = new FlowTriggers(this::onTriggerFired); + + public FlowBuilder() { + super(Categories.Baritone, "flow-builder", "Visual node-flow editor for chaining Baritone tasks and Meteor module actions."); + + // Keeps armed flows' triggers watched, and any running flow (e.g. Leave -> Wait -> Reconnect) progressing, across disconnects. + runInMainMenu = true; + } + + @Override + public void onActivate() { + String name = runOnEnable.get(); + if (name != null && !name.isBlank()) { + Flow flow = BaritoneFlows.get().get(name); + if (flow != null) runner.start(flow); + else warning("No saved flow named '%s'.", name); + } + } + + @Override + public void onDeactivate() { + runner.stop(); + triggers.clear(); + } + + @EventHandler + private void onTick(TickEvent.Post event) { + runner.tick(); + triggers.tick(); + } + + @EventHandler + private void onDisconnectReason(DisconnectReasonEvent event) { + runner.setLastDisconnectReason(event.reason); + triggers.onDisconnect(event.reason); + } + + @EventHandler + private void onRender2D(Render2DEvent event) { + if (!showEta.get()) return; + + String eta = runner.getEtaText(); + if (eta == null) return; + + VanillaTextRenderer text = VanillaTextRenderer.INSTANCE; + + text.begin(1, false, false); + double w = text.getWidth(eta); + double h = text.getHeight(); + + double margin = 6; + double x = Utils.getWindowWidth() - w - margin; + double y = margin; + + Renderer2D.COLOR.begin(); + Renderer2D.COLOR.quad(x - 4, y - 3, w + 8, h + 6, ETA_BG); + Renderer2D.COLOR.render(); + + text.render(eta, x, y, ETA_TEXT, true); + text.end(); + } + + /** Starts a flow, enabling the module first so it gets ticked. Called from the editor / flow list. */ + public void runFlow(Flow flow) { + if (!isActive()) toggle(); + runner.start(flow); + info("Running flow '%s'.", flow.name); + } + + // Only ever called from onTick(), which only runs while this module is already active. + private void onTriggerFired(Flow flow, FlowNode node) { + runner.interrupt(flow, node); + info("Trigger '%s' fired in '%s'.", node.title(), flow.name); + } + + public void stopAll() { + runner.stop(); + if (BaritoneUtils.IS_AVAILABLE) PathManagers.get().stop(); + } + + /** Pauses the running flow itself (not just Baritone), so it won't skip ahead to the next queued node. */ + public void pauseRunner() { + runner.pause(); + } + + public void resumeRunner() { + runner.resume(); + } + + public boolean isRunnerPaused() { + return runner.isPaused(); + } + + public boolean isRunningFlow() { + return runner.isRunning(); + } + + public Flow getRunningFlow() { + return runner.getFlow(); + } + + public FlowNode getCurrentNode() { + return runner.getCurrentNode(); + } + + @Override + public WWidget getWidget(GuiTheme theme) { + WHorizontalList list = theme.horizontalList(); + + WButton flows = list.add(theme.button("Flows & Editor")).expandCellX().widget(); + flows.action = () -> mc.setScreen(new FlowsScreen(theme)); + + WButton disconnectLog = list.add(theme.button("Disconnect Log")).expandCellX().widget(); + disconnectLog.action = () -> mc.setScreen(new DisconnectLogScreen(theme)); + + return list; + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Follow.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Follow.java new file mode 100644 index 00000000000..a0f5b1c3060 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Follow.java @@ -0,0 +1,66 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.settings.EnumSetting; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.settings.StringSetting; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; + +import java.util.function.Predicate; + +/** One-shot command: press its keybind to have Baritone follow the configured target. */ +public class Follow extends Module { + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + + private final Setting target = sgGeneral.add(new EnumSetting.Builder() + .name("target") + .description("Who to follow.") + .defaultValue(Target.NearestPlayer) + .build() + ); + + private final Setting playerName = sgGeneral.add(new StringSetting.Builder() + .name("player-name") + .description("Name of the player to follow.") + .defaultValue("") + .visible(() -> target.get() == Target.PlayerByName) + .build() + ); + + public Follow() { + super(Categories.Baritone, "follow", "Has Baritone follow the configured target."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) PathManagers.get().follow(predicate()); + + toggle(); + } + + private Predicate predicate() { + return switch (target.get()) { + case NearestPlayer -> entity -> entity instanceof Player && entity != mc.player; + case NearestEntity -> entity -> entity != mc.player && entity.isAlive(); + case PlayerByName -> { + String name = playerName.get(); + yield entity -> entity instanceof Player && entity.getName().getString().equalsIgnoreCase(name); + } + }; + } + + public enum Target { + NearestPlayer, + NearestEntity, + PlayerByName + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Goto.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Goto.java new file mode 100644 index 00000000000..2fa8a89866e --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Goto.java @@ -0,0 +1,44 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.settings.BlockPosSetting; +import meteordevelopment.meteorclient.settings.BoolSetting; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; +import net.minecraft.core.BlockPos; + +/** One-shot command: press its keybind to send Baritone to the configured target. */ +public class Goto extends Module { + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + + private final Setting target = sgGeneral.add(new BlockPosSetting.Builder() + .name("target") + .description("Destination to path to.") + .build() + ); + + private final Setting ignoreY = sgGeneral.add(new BoolSetting.Builder() + .name("ignore-y") + .description("Reach the X and Z of the target, ignoring its Y.") + .defaultValue(false) + .build() + ); + + public Goto() { + super(Categories.Baritone, "goto", "Sends Baritone to a fixed coordinate."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) PathManagers.get().moveTo(target.get(), ignoreY.get()); + + toggle(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Mine.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Mine.java new file mode 100644 index 00000000000..cd7c974153b --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Mine.java @@ -0,0 +1,41 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.settings.BlockListSetting; +import meteordevelopment.meteorclient.settings.Setting; +import meteordevelopment.meteorclient.settings.SettingGroup; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; +import net.minecraft.world.level.block.Block; + +import java.util.List; + +/** One-shot command: press its keybind to have Baritone mine the configured blocks. */ +public class Mine extends Module { + private final SettingGroup sgGeneral = settings.getDefaultGroup(); + + private final Setting> blocks = sgGeneral.add(new BlockListSetting.Builder() + .name("blocks") + .description("Blocks to mine.") + .build() + ); + + public Mine() { + super(Categories.Baritone, "mine", "Has Baritone mine the configured blocks."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) { + if (blocks.get().isEmpty()) warning("No blocks selected."); + else PathManagers.get().mine(blocks.get().toArray(new Block[0])); + } + + toggle(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Pause.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Pause.java new file mode 100644 index 00000000000..079857cc8d4 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Pause.java @@ -0,0 +1,28 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.systems.modules.Modules; + +/** One-shot command: press its keybind to pause Baritone's current path (and any running flow). */ +public class Pause extends Module { + public Pause() { + super(Categories.Baritone, "pause", "Pauses Baritone's current path (and any running flow)."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) PathManagers.get().pause(); + + FlowBuilder flowBuilder = Modules.get().get(FlowBuilder.class); + if (flowBuilder.isRunningFlow()) flowBuilder.pauseRunner(); + + toggle(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Resume.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Resume.java new file mode 100644 index 00000000000..baeaf508486 --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Resume.java @@ -0,0 +1,28 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; +import meteordevelopment.meteorclient.systems.modules.Modules; + +/** One-shot command: press its keybind to resume a path (and any running flow) paused by {@link Pause}. */ +public class Resume extends Module { + public Resume() { + super(Categories.Baritone, "resume", "Resumes Baritone's paused path (and any paused flow)."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) PathManagers.get().resume(); + + FlowBuilder flowBuilder = Modules.get().get(FlowBuilder.class); + if (flowBuilder.isRunningFlow()) flowBuilder.resumeRunner(); + + toggle(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Stop.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Stop.java new file mode 100644 index 00000000000..5541c5eed2e --- /dev/null +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/baritone/Stop.java @@ -0,0 +1,24 @@ +/* + * This file is part of the Meteor Client distribution (https://github.com/MeteorDevelopment/meteor-client). + * Copyright (c) Meteor Development. + */ + +package meteordevelopment.meteorclient.systems.modules.baritone; + +import meteordevelopment.meteorclient.pathing.PathManagers; +import meteordevelopment.meteorclient.systems.modules.Categories; +import meteordevelopment.meteorclient.systems.modules.Module; + +/** One-shot command: press its keybind to cancel Baritone's current path. */ +public class Stop extends Module { + public Stop() { + super(Categories.Baritone, "stop", "Cancels Baritone's current path."); + } + + @Override + public void onActivate() { + if (BaritoneUtil.check(this)) PathManagers.get().stop(); + + toggle(); + } +} diff --git a/src/main/java/meteordevelopment/meteorclient/systems/modules/player/AutoEat.java b/src/main/java/meteordevelopment/meteorclient/systems/modules/player/AutoEat.java index c7aafdc8900..bb8caec1ef8 100644 --- a/src/main/java/meteordevelopment/meteorclient/systems/modules/player/AutoEat.java +++ b/src/main/java/meteordevelopment/meteorclient/systems/modules/player/AutoEat.java @@ -19,16 +19,20 @@ import meteordevelopment.meteorclient.systems.modules.combat.KillAura; import meteordevelopment.meteorclient.utils.Utils; import meteordevelopment.meteorclient.utils.player.InvUtils; +import meteordevelopment.meteorclient.utils.player.PlayerUtils; import meteordevelopment.meteorclient.utils.player.SlotUtils; import meteordevelopment.orbit.EventHandler; import meteordevelopment.orbit.EventPriority; import net.minecraft.core.component.DataComponents; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.MobCategory; import net.minecraft.world.food.FoodProperties; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import java.util.List; +import java.util.Set; import java.util.function.BiPredicate; public class AutoEat extends Module { @@ -115,6 +119,39 @@ public class AutoEat extends Module { .build() ); + // Combat Hold + private final SettingGroup sgCombat = settings.createGroup("Combat Hold"); + + private final Setting combatHold = sgCombat.add(new BoolSetting.Builder() + .name("hold-near-mobs") + .description("Don't eat while hostile mobs are near and you're still healthy, so healing food is saved for emergencies.") + .defaultValue(false) + .build() + ); + + private final Setting combatRange = sgCombat.add(new DoubleSetting.Builder() + .name("mob-range") + .description("Radius to check for hostile mobs.") + .defaultValue(8) + .min(1) + .sliderRange(1, 32) + .visible(combatHold::get) + .build() + ); + + private final Setting combatHealth = sgCombat.add(new DoubleSetting.Builder() + .name("min-health") + .description("Only hold eating while your health is at or above this. Drop below it and eating is allowed again.") + .defaultValue(10) + .range(1, 20) + .sliderRange(1, 20) + .visible(combatHold::get) + .build() + ); + + // Items that restore health (via regeneration/absorption) - only these count as emergency healing food. + private static final Set HEALING_FOOD = Set.of(Items.GOLDEN_APPLE, Items.ENCHANTED_GOLDEN_APPLE); + // Module state public boolean eating; private int slot, prevSlot; @@ -266,6 +303,10 @@ private boolean changeSlot(int slot) { public boolean shouldEat() { if (mc.player == null) return false; + + // Combat hold: don't eat while healthy and near mobs (save healing food for emergencies). + if (combatHold.get() && shouldHoldForCombat()) return false; + boolean healthLow = mc.player.getHealth() <= healthThreshold.get(); boolean hungerLow = mc.player.getFoodData().getFoodLevel() <= hungerThreshold.get(); if (!thresholdMode.get().test(healthLow, hungerLow)) return false; @@ -280,6 +321,36 @@ public boolean shouldEat() { return (mc.player.getFoodData().needsFood() || prop.canAlwaysEat()); } + /** + * True when eating should be held back: we're still healthy, there is HP-restoring food worth + * saving, and at least one hostile mob is within range. + */ + private boolean shouldHoldForCombat() { + if (mc.player.getHealth() < combatHealth.get()) return false; + if (!hasHealingFood()) return false; + + return hostileNearby(combatRange.get()); + } + + private boolean hasHealingFood() { + for (int i = 0; i < mc.player.getInventory().getContainerSize(); i++) { + if (HEALING_FOOD.contains(mc.player.getInventory().getItem(i).getItem())) return true; + } + return false; + } + + private boolean hostileNearby(double range) { + if (mc.level == null) return false; + + for (Entity entity : mc.level.entitiesForRendering()) { + if (entity == mc.player || !entity.isAlive()) continue; + if (entity.getType().getCategory() != MobCategory.MONSTER) continue; + if (PlayerUtils.isWithin(entity, range)) return true; + } + + return false; + } + /** * Finds the best slot to eat from, preferring: * offhand => hotbar => main inventory (if allowed). diff --git a/src/main/resources/meteor-client.mixins.json b/src/main/resources/meteor-client.mixins.json index 968a4145d13..4d1162e2ffc 100644 --- a/src/main/resources/meteor-client.mixins.json +++ b/src/main/resources/meteor-client.mixins.json @@ -58,6 +58,7 @@ "ClientboundSetEntityMotionPacketAccessor", "ClientChunkCacheAccessor", "ClientChunkMapAccessor", + "ClientCommonPacketListenerMixin", "ClientLevelMixin", "ClientPacketListenerAccessor", "ClientPacketListenerMixin",