From 4587b701cd276e2fd2ed8403e377851127c41104 Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 14:08:29 +0200 Subject: [PATCH 1/5] Add Alert node for out-of-game notifications Automation runs while the player is tabbed out or AFK, so a graph needs a way to reach someone who is not looking at the screen. Send Message only reaches chat, which nobody sees while a farm runs unattended. Alert has two modes. Play Sound takes any vanilla sound id plus a volume, and plays it through the sound manager with SimpleSoundInstance.forUI so the alert is not attenuated by where the player happens to be standing. Send Webhook POSTs the node's text to an endpoint configured in settings, which is what reaches a phone when nobody is at the machine. Non-obvious calls made here: - The node completes its future as soon as the notification is dispatched rather than awaiting the response. An alert must never stall the graph it is reporting on, so webhook failures are logged rather than propagated. - Webhook sends are rate limited to one per three seconds. An Alert placed inside Forever would otherwise hammer the endpoint, and Discord kills webhooks that are hammered. - Payload shape is chosen by host: Discord needs a JSON body, ntfy takes the raw text. This is a deliberate two-case heuristic rather than another mode. - The URL is validated once in SettingsManager.sanitizeWebhookUrl and https is required. It is a user-supplied string driving an outbound request from the game client, so it is checked at the boundary instead of at each call site. - Alert reuses the multi-line text fields that Send Message and Calculate already have, by joining NodeTextContent, so message text supports runtime variable interpolation with no new UI. - Toast was considered and left out: it only helps someone already watching the screen, which is not the case this node exists for. Not yet wired: the webhook URL has no settings-popup field, so it is set by editing pathmind/settings.json. That control lives in a Stonecutter source file and is left for a follow-up so this change stays reviewable. Verified: Fabric compiles on 26.2 and NeoForge on 26.1.2, and the generated mc26 source is byte-identical to the authored file, so no string transform was needed. The escaping and URL-validation logic was checked separately against the same inputs as NodeAlertTest. Not verified locally: :common:test and the 1.21.x targets, which need a JDK 21 toolchain; CI covers both. Co-Authored-By: Claude Opus 5 --- .../com/pathmind/data/SettingsManager.java | 30 +++ .../nodes/NodeAlertCommandExecutor.java | 218 ++++++++++++++++++ .../java/com/pathmind/nodes/NodeCatalog.java | 12 +- .../pathmind/nodes/NodeCommandDispatcher.java | 1 + .../java/com/pathmind/nodes/NodeMode.java | 7 + .../com/pathmind/nodes/NodeTextContent.java | 4 +- .../java/com/pathmind/nodes/NodeType.java | 1 + .../resources/assets/pathmind/lang/en_us.json | 4 + .../com/pathmind/nodes/NodeAlertTest.java | 75 ++++++ 9 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java create mode 100644 common/src/test/java/com/pathmind/nodes/NodeAlertTest.java diff --git a/common/src/main/java/com/pathmind/data/SettingsManager.java b/common/src/main/java/com/pathmind/data/SettingsManager.java index 64628799..529d780f 100644 --- a/common/src/main/java/com/pathmind/data/SettingsManager.java +++ b/common/src/main/java/com/pathmind/data/SettingsManager.java @@ -48,6 +48,7 @@ public static class Settings { public Boolean gotoAllowBreakWhileExecuting = false; public Boolean gotoAllowPlaceWhileExecuting = false; public Boolean keyPressedActivatesInGuis = true; + public String alertWebhookUrl = ""; public Boolean createListUseCustomRadius = false; public Integer createListRadius = 64; public Map presetGroupColors = new LinkedHashMap<>(); @@ -145,6 +146,35 @@ public static boolean shouldShowChatErrors() { return settings.showChatErrors == null || settings.showChatErrors; } + /** + * The configured Alert webhook endpoint, or null when unset or not a usable https URL. + * Validated here rather than at the call site so every caller gets the same trust boundary. + */ + public static String getAlertWebhookUrl() { + return sanitizeWebhookUrl(getCurrent().alertWebhookUrl); + } + + /** + * Returns the URL only when it is a usable https endpoint, otherwise null. Plain http and + * every other scheme are rejected: this string comes from the user and drives an outbound + * request, so it is checked once here rather than at each call site. + */ + public static String sanitizeWebhookUrl(String candidate) { + String raw = candidate == null ? "" : candidate.trim(); + if (raw.isEmpty()) { + return null; + } + try { + java.net.URI uri = java.net.URI.create(raw); + boolean usable = "https".equalsIgnoreCase(uri.getScheme()) + && uri.getHost() != null + && !uri.getHost().isBlank(); + return usable ? raw : null; + } catch (IllegalArgumentException invalid) { + return null; + } + } + public static long getNodeDelayMs() { Settings settings = getCurrent(); int delay = settings.nodeDelayMs == null ? 150 : settings.nodeDelayMs; diff --git a/common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java b/common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java new file mode 100644 index 00000000..818bef58 --- /dev/null +++ b/common/src/main/java/com/pathmind/nodes/NodeAlertCommandExecutor.java @@ -0,0 +1,218 @@ +package com.pathmind.nodes; + +import static com.pathmind.util.PathmindI18n.tr; + +import com.pathmind.PathmindCommon; +import com.pathmind.data.SettingsManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.sounds.SoundEvent; + +/** + * Out-of-game notification. Automation runs while the player is tabbed out or AFK, so an + * alert has to reach someone who is not looking at the screen: a sound for when you are at + * the machine, a webhook for when you are not. + */ +final class NodeAlertCommandExecutor { + /** Discord kills webhooks that are hammered, and an Alert inside Forever would do exactly that. */ + private static final long WEBHOOK_MIN_INTERVAL_MS = 3_000L; + private static final AtomicLong LAST_WEBHOOK_SEND_MS = new AtomicLong(0L); + + private static final String DEFAULT_SOUND_ID = "minecraft:block.note_block.pling"; + private static final Duration WEBHOOK_TIMEOUT = Duration.ofSeconds(10); + + private static volatile HttpClient httpClient; + + private final Node owner; + + NodeAlertCommandExecutor(Node owner) { + this.owner = owner; + } + + void executeAlertCommand(CompletableFuture future) { + if (preprocessAttachedParameter(future) == Node.ParameterHandlingResult.COMPLETE) { + return; + } + + Minecraft client = Minecraft.getInstance(); + String text = resolveAlertText(); + + if (owner.getMode() == NodeMode.ALERT_WEBHOOK) { + sendWebhook(client, text); + } else { + playSound(client); + } + + // An alert must never stall the graph it is reporting on, so the node completes as soon + // as the notification is dispatched rather than waiting on the network. + future.complete(null); + } + + private String resolveAlertText() { + List lines = getMessageLines(); + if (lines == null || lines.isEmpty()) { + return "Pathmind alert"; + } + StringBuilder builder = new StringBuilder(); + for (String raw : lines) { + String line = raw == null ? "" : raw.trim(); + if (line.isEmpty()) { + continue; + } + if (!builder.isEmpty()) { + builder.append('\n'); + } + builder.append(owner.resolveRuntimeVariablesInText(line)); + } + return builder.isEmpty() ? "Pathmind alert" : builder.toString(); + } + + // ---------------------------------------------------------------- sound + + private void playSound(Minecraft client) { + if (client == null) { + return; + } + String soundId = owner.getStringParameter("Sound", DEFAULT_SOUND_ID); + SoundEvent soundEvent = resolveSoundEvent(soundId); + if (soundEvent == null) { + sendNodeErrorMessage(client, tr("pathmind.error.unknownSound", soundId)); + return; + } + float volume = (float) Math.max(0.0, Math.min(1.0, owner.getDoubleParameter("Volume", 1.0))); + if (volume <= 0.0F) { + return; + } + client.execute(() -> { + try { + // forUI keeps the alert at full volume regardless of where the player is standing, + // which is the whole point of an alert. + client.getSoundManager().play(SimpleSoundInstance.forUI(soundEvent, 1.0F, volume)); + } catch (RuntimeException | LinkageError error) { + PathmindCommon.LOGGER.warn("Alert node failed to play a sound", error); + } + }); + } + + private static SoundEvent resolveSoundEvent(String soundId) { + String candidate = soundId == null || soundId.isBlank() ? DEFAULT_SOUND_ID : soundId.trim(); + Identifier identifier = Identifier.tryParse(candidate); + if (identifier == null) { + return null; + } + return BuiltInRegistries.SOUND_EVENT.getOptional(identifier).orElse(null); + } + + // -------------------------------------------------------------- webhook + + private void sendWebhook(Minecraft client, String text) { + String url = SettingsManager.getAlertWebhookUrl(); + if (url == null) { + sendNodeErrorMessage(client, tr("pathmind.error.alertWebhookUnset")); + return; + } + + long now = System.currentTimeMillis(); + long previous = LAST_WEBHOOK_SEND_MS.get(); + if (now - previous < WEBHOOK_MIN_INTERVAL_MS + || !LAST_WEBHOOK_SEND_MS.compareAndSet(previous, now)) { + PathmindCommon.LOGGER.debug("Alert webhook skipped: minimum interval not elapsed"); + return; + } + + HttpRequest request; + try { + request = buildRequest(url, text); + } catch (IllegalArgumentException invalid) { + sendNodeErrorMessage(client, tr("pathmind.error.alertWebhookUnset")); + return; + } + + client().sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .thenAccept(response -> { + if (response.statusCode() >= 300) { + PathmindCommon.LOGGER.warn("Alert webhook returned HTTP {}", response.statusCode()); + } + }) + .exceptionally(error -> { + PathmindCommon.LOGGER.warn("Alert webhook failed to send", error); + return null; + }); + } + + private static HttpRequest buildRequest(String url, String text) { + // ponytail: two payload shapes cover the endpoints people actually use - Discord wants + // JSON, ntfy takes the raw body. Add a Format parameter if a third shape shows up. + boolean discord = URI.create(url).getHost().toLowerCase(java.util.Locale.ROOT).contains("discord"); + String body = discord ? "{\"content\":" + jsonString(text) + "}" : text; + return HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(WEBHOOK_TIMEOUT) + .header("Content-Type", discord ? "application/json" : "text/plain; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(); + } + + static String jsonString(String value) { + String source = value == null ? "" : value; + StringBuilder out = new StringBuilder(source.length() + 2).append('"'); + for (int i = 0; i < source.length(); i++) { + char c = source.charAt(i); + switch (c) { + case '"' -> out.append("\\\""); + case '\\' -> out.append("\\\\"); + case '\n' -> out.append("\\n"); + case '\r' -> out.append("\\r"); + case '\t' -> out.append("\\t"); + default -> { + if (c < 0x20) { + out.append(String.format("\\u%04x", (int) c)); + } else { + out.append(c); + } + } + } + } + return out.append('"').toString(); + } + + private static HttpClient client() { + HttpClient existing = httpClient; + if (existing == null) { + synchronized (NodeAlertCommandExecutor.class) { + existing = httpClient; + if (existing == null) { + existing = HttpClient.newBuilder().connectTimeout(WEBHOOK_TIMEOUT).build(); + httpClient = existing; + } + } + } + return existing; + } + + // ------------------------------------------------------------ delegates + + private Node.ParameterHandlingResult preprocessAttachedParameter(CompletableFuture future) { + return owner.preprocessAttachedParameter(EnumSet.noneOf(Node.ParameterUsage.class), future); + } + + private List getMessageLines() { + return owner.getMessageLines(); + } + + private void sendNodeErrorMessage(Minecraft client, String message) { + owner.sendNodeErrorMessage(client, message); + } +} diff --git a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java index c5997502..d9a4cad6 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java @@ -145,6 +145,7 @@ public final class NodeCatalog { NodeType.EQUIP_HAND, NodeType.UI_UTILS, NodeType.MESSAGE, + NodeType.ALERT, NodeType.STICKY_NOTE); define(NodeCategory.DATA, @@ -555,6 +556,7 @@ public final class NodeCatalog { NodeType.UI_UTILS); sidebar(NodeCategory.INTERFACE, "pathmind.sidebar.group.writingOutput", NodeType.MESSAGE, + NodeType.ALERT, NodeType.WRITE_BOOK, NodeType.WRITE_SIGN); @@ -903,6 +905,9 @@ public final class NodeCatalog { modeParameters(NodeMode.FARM_WAYPOINT, of("Waypoint", ParameterType.STRING, "farm"), of("Range", ParameterType.INTEGER, "10")); + modeParameters(NodeMode.ALERT_SOUND, + of("Sound", ParameterType.STRING, "minecraft:block.note_block.pling"), + of("Volume", ParameterType.DOUBLE, "1.0")); modeParameters(NodeMode.WAIT_SECONDS, of("Duration", ParameterType.DOUBLE, "")); modeParameters(NodeMode.WAIT_TICKS, of("Duration", ParameterType.DOUBLE, "")); modeParameters(NodeMode.WAIT_MINUTES, of("Duration", ParameterType.DOUBLE, "")); @@ -1156,6 +1161,7 @@ public final class NodeCatalog { route(ExecutionRoute.UI_UTILS, NodeType.UI_UTILS); route(ExecutionRoute.WAIT, NodeType.WAIT); route(ExecutionRoute.MESSAGE, NodeType.MESSAGE); + route(ExecutionRoute.ALERT, NodeType.ALERT); route(ExecutionRoute.HOTBAR, NodeType.HOTBAR); route(ExecutionRoute.DROP_ITEM, NodeType.DROP_ITEM); route(ExecutionRoute.DROP_SLOT, NodeType.DROP_SLOT); @@ -1571,6 +1577,7 @@ private static String nameKey(NodeType type) { case WAIT -> "pathmind.node.type.wait"; case STICKY_NOTE -> "pathmind.node.type.stickyNote"; case MESSAGE -> "pathmind.node.type.message"; + case ALERT -> "pathmind.node.type.alert"; case TEMPLATE -> "pathmind.node.type.template"; case STOP_CHAIN -> "pathmind.node.type.stopChain"; case STOP_ALL -> "pathmind.node.type.stopAll"; @@ -1722,6 +1729,7 @@ private static String descriptionKey(NodeType type) { case WAIT -> "pathmind.node.type.wait.desc"; case STICKY_NOTE -> "pathmind.node.type.stickyNote.desc"; case MESSAGE -> "pathmind.node.type.message.desc"; + case ALERT -> "pathmind.node.type.alert.desc"; case TEMPLATE -> "pathmind.node.type.template.desc"; case STOP_CHAIN -> "pathmind.node.type.stopChain.desc"; case STOP_ALL -> "pathmind.node.type.stopAll.desc"; @@ -1871,6 +1879,7 @@ private static int baseColor(NodeType type) { case WAIT -> 0xFF607D8B; case STICKY_NOTE -> 0xFFEBCB5B; case MESSAGE -> 0xFF9E9E9E; + case ALERT -> 0xFFFF8F00; case TEMPLATE -> 0xFF26A69A; case STOP_CHAIN -> 0xFFE53935; case STOP_ALL -> 0xFFE53935; @@ -2100,7 +2109,8 @@ public enum ExecutionRoute { INVERT, COME, SURFACE, - TUNNEL + TUNNEL, + ALERT } private record SidebarGroupDefinition( diff --git a/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java b/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java index 41d0d4c2..036d1599 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCommandDispatcher.java @@ -51,6 +51,7 @@ static void execute(Node node, CompletableFuture future) { case UI_UTILS -> new NodeGuiCommandExecutor(node).executeUiUtilsCommand(future); case WAIT -> new NodeFlowCommandExecutor(node).executeWaitCommand(future); case MESSAGE -> new NodeTextIoCommandExecutor(node).executeMessageCommand(future); + case ALERT -> new NodeAlertCommandExecutor(node).executeAlertCommand(future); case HOTBAR -> new NodeInventoryCommandExecutor(node).executeHotbarCommand(future); case DROP_ITEM -> new NodeInventoryCommandExecutor(node).executeDropItemCommand(future); case DROP_SLOT -> new NodeInventoryCommandExecutor(node).executeDropSlotCommand(future); diff --git a/common/src/main/java/com/pathmind/nodes/NodeMode.java b/common/src/main/java/com/pathmind/nodes/NodeMode.java index 870219d6..aedcc0af 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeMode.java +++ b/common/src/main/java/com/pathmind/nodes/NodeMode.java @@ -8,6 +8,9 @@ * Each mode corresponds to a specific behavior within a generalized node type. */ public enum NodeMode { + // ALERT modes + ALERT_SOUND("Play Sound", "Play a sound so you notice while tabbed out or AFK"), + ALERT_WEBHOOK("Send Webhook", "POST the text to the webhook URL set in Pathmind settings"), // GOTO modes GOTO_XYZ("Go to XYZ", "Go to specific X, Y, Z coordinates"), GOTO_XZ("Go to XZ", "Go to X, Z coordinates (Y defaults to surface)"), @@ -176,6 +179,9 @@ public static NodeMode[] getModesForNodeType(NodeType nodeType) { case SENSOR_LOOK_DIRECTION -> new NodeMode[]{ SENSOR_LOOK_YAW, SENSOR_LOOK_PITCH, SENSOR_LOOK_ROTATION }; + case ALERT -> new NodeMode[]{ + ALERT_SOUND, ALERT_WEBHOOK + }; default -> new NodeMode[0]; }; } @@ -200,6 +206,7 @@ public static NodeMode getDefaultModeForNodeType(NodeType nodeType) { case WAIT, PARAM_DURATION -> WAIT_SECONDS; case SENSOR_POSITION_OF -> SENSOR_POSITION_XYZ; case SENSOR_LOOK_DIRECTION -> SENSOR_LOOK_ROTATION; + case ALERT -> ALERT_SOUND; default -> null; }; } diff --git a/common/src/main/java/com/pathmind/nodes/NodeTextContent.java b/common/src/main/java/com/pathmind/nodes/NodeTextContent.java index 2e8d2810..b3864b72 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeTextContent.java +++ b/common/src/main/java/com/pathmind/nodes/NodeTextContent.java @@ -19,7 +19,7 @@ interface Host { this.type = type; this.host = host; this.messageLines = new ArrayList<>(); - if (type == NodeType.MESSAGE || type == NodeType.CALCULATE) { + if (type == NodeType.MESSAGE || type == NodeType.CALCULATE || type == NodeType.ALERT) { this.messageLines.add(getDefaultMessageLineValue()); } this.messageClientSide = false; @@ -129,7 +129,7 @@ String getMessageFieldLabelText(int index) { } boolean hasMessageInputFields() { - return type == NodeType.MESSAGE || type == NodeType.CALCULATE; + return type == NodeType.MESSAGE || type == NodeType.CALCULATE || type == NodeType.ALERT; } boolean hasBookTextInput() { diff --git a/common/src/main/java/com/pathmind/nodes/NodeType.java b/common/src/main/java/com/pathmind/nodes/NodeType.java index 5d929c8b..6854d2f1 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeType.java +++ b/common/src/main/java/com/pathmind/nodes/NodeType.java @@ -150,6 +150,7 @@ public enum NodeType { WAIT, STICKY_NOTE, MESSAGE, + ALERT, TEMPLATE, STOP_CHAIN, STOP_ALL, diff --git a/common/src/main/resources/assets/pathmind/lang/en_us.json b/common/src/main/resources/assets/pathmind/lang/en_us.json index a12e7843..1ecabddb 100644 --- a/common/src/main/resources/assets/pathmind/lang/en_us.json +++ b/common/src/main/resources/assets/pathmind/lang/en_us.json @@ -168,6 +168,8 @@ "pathmind.error.unknownBlockForNode": "Unknown block \"%s\" for %s.", "pathmind.error.unknownItemForNode": "Unknown item \"%s\" for %s.", "pathmind.error.unknownKey": "Unknown key: %s", + "pathmind.error.unknownSound": "Unknown sound: %s", + "pathmind.error.alertWebhookUnset": "Set an https webhook URL in Pathmind settings before using Send Webhook", "pathmind.error.unknownMouseButton": "Unknown mouse button: %s", "pathmind.error.unspecifiedState": "(unspecified state)", "pathmind.error.useCannotActivateArmorSlots": "Use node cannot activate armor slots.", @@ -563,6 +565,8 @@ "pathmind.node.type.look.desc": "Adjusts the player's view direction", "pathmind.node.type.message": "Send Message", "pathmind.node.type.message.desc": "Displays text or sends a message in chat", + "pathmind.node.type.alert": "Alert", + "pathmind.node.type.alert.desc": "Notifies you outside the game: plays a sound, or posts the text to your webhook", "pathmind.node.type.template": "Preset", "pathmind.node.type.template.desc": "References another preset with a live preview", "pathmind.node.type.moveItem": "Move Item", diff --git a/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java new file mode 100644 index 00000000..a4d6b9ee --- /dev/null +++ b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java @@ -0,0 +1,75 @@ +package com.pathmind.nodes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.pathmind.data.SettingsManager; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class NodeAlertTest { + + @Test + void jsonStringEscapesCharactersThatWouldBreakTheDiscordPayload() { + // A message containing a quote or a newline is ordinary user text, and unescaped it + // produces a malformed body that Discord rejects with a 400. + assertEquals("\"plain\"", NodeAlertCommandExecutor.jsonString("plain")); + assertEquals("\"say \\\"hi\\\"\"", NodeAlertCommandExecutor.jsonString("say \"hi\"")); + assertEquals("\"a\\\\b\"", NodeAlertCommandExecutor.jsonString("a\\b")); + assertEquals("\"line1\\nline2\"", NodeAlertCommandExecutor.jsonString("line1\nline2")); + assertEquals("\"tab\\there\"", NodeAlertCommandExecutor.jsonString("tab\there")); + assertEquals("\"\"", NodeAlertCommandExecutor.jsonString(null)); + } + + @Test + void jsonStringEscapesControlCharactersAsUnicode() { + assertEquals("\"\\u0007\"", NodeAlertCommandExecutor.jsonString("\u0007")); + } + + @Test + void jsonStringLeavesNonAsciiTextIntact() { + // The body is sent as UTF-8, so accented and non-Latin text must survive unescaped. + assertEquals("\"raid finí\"", NodeAlertCommandExecutor.jsonString("raid finí")); + } + + @Test + void webhookUrlAcceptsHttpsEndpoints() { + assertNotNull(SettingsManager.sanitizeWebhookUrl("https://ntfy.sh/my-topic")); + assertNotNull(SettingsManager.sanitizeWebhookUrl( + "https://discord.com/api/webhooks/123/abc")); + assertEquals("https://ntfy.sh/t", + SettingsManager.sanitizeWebhookUrl(" https://ntfy.sh/t ")); + } + + @Test + void webhookUrlRejectsAnythingThatIsNotHttps() { + // This string comes from the user and drives an outbound request from the game client, + // so every non-https scheme has to be refused rather than merely discouraged. + List rejected = Arrays.asList( + null, + "", + " ", + "http://ntfy.sh/my-topic", + "ftp://example.com/hook", + "file:///C:/windows/system32", + "javascript:alert(1)", + "not a url at all", + "https://", + "://missing-scheme"); + for (String candidate : rejected) { + assertNull(SettingsManager.sanitizeWebhookUrl(candidate), + "expected rejection for: " + candidate); + } + } + + @Test + void alertIsRegisteredAsARoutedInterfaceNode() { + assertTrue(NodeCatalog.hasExecutionRoute(NodeType.ALERT)); + assertEquals(NodeCategory.INTERFACE, NodeCatalog.category(NodeType.ALERT)); + assertEquals(NodeMode.ALERT_SOUND, NodeMode.getDefaultModeForNodeType(NodeType.ALERT)); + assertEquals(2, NodeMode.getAvailableModesForNodeType(NodeType.ALERT).length); + } +} From 5feaeb452b2dbeeb7640434933734f9649316d6b Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 14:08:30 +0200 Subject: [PATCH 2/5] Fix NodeAlertTest to call the mode lookup that exists NodeMode has getModesForNodeType, not getAvailableModesForNodeType, so the assertion would not have compiled. Co-Authored-By: Claude Opus 5 --- common/src/test/java/com/pathmind/nodes/NodeAlertTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java index a4d6b9ee..140f0cbf 100644 --- a/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java +++ b/common/src/test/java/com/pathmind/nodes/NodeAlertTest.java @@ -70,6 +70,6 @@ void alertIsRegisteredAsARoutedInterfaceNode() { assertTrue(NodeCatalog.hasExecutionRoute(NodeType.ALERT)); assertEquals(NodeCategory.INTERFACE, NodeCatalog.category(NodeType.ALERT)); assertEquals(NodeMode.ALERT_SOUND, NodeMode.getDefaultModeForNodeType(NodeType.ALERT)); - assertEquals(2, NodeMode.getAvailableModesForNodeType(NodeType.ALERT).length); + assertEquals(2, NodeMode.getModesForNodeType(NodeType.ALERT).length); } } From fdd8847fd745c02e1f160937c779153676b7ff2a Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 16:22:34 +0200 Subject: [PATCH 3/5] Make the Alert node's mode and parameters reachable in the editor Alert shipped unusable: it rendered only its message field, so the Sound and Volume parameters were invisible and there was no way to switch to Send Webhook. Its mode names also displayed as raw keys. Two causes, both missed because the editor has no automated coverage: - NodeGraph.rendersInlineParameters decides whether a node draws its parameter strip, and the mode selector is drawn inside that strip. It is true only for parameter nodes and for types tagged RENDER_INLINE_PARAMETERS. Alert was neither, so the strip that would have held both never rendered. - NodeMode.getDisplayName ignores the display strings in the enum constructor and builds "pathmind.node.mode." as a translation key. Without lang entries the modes rendered as pathmind.node.mode.alert_sound. Verified in a dev client: the mode selector opens, Sound and Volume are editable, and both modes read as English. Co-Authored-By: Claude Opus 5 --- common/src/main/java/com/pathmind/nodes/NodeCatalog.java | 4 ++++ common/src/main/resources/assets/pathmind/lang/en_us.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java index d9a4cad6..1474ff11 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java @@ -441,7 +441,11 @@ public final class NodeCatalog { NodeType.OPEN_INVENTORY, NodeType.CLOSE_GUI); + // This flag is what makes a node draw its parameter strip, and the mode selector lives + // inside that strip (NodeRenderer.renderInlineParameterContent). A node with modes but + // without this tag has no way to reach them in the editor. tag(NodeFlag.RENDER_INLINE_PARAMETERS, + NodeType.ALERT, NodeType.UI_UTILS, NodeType.SENSOR_FABRIC_EVENT, NodeType.SENSOR_ATTRIBUTE_DETECTION, diff --git a/common/src/main/resources/assets/pathmind/lang/en_us.json b/common/src/main/resources/assets/pathmind/lang/en_us.json index 1ecabddb..4af4bde6 100644 --- a/common/src/main/resources/assets/pathmind/lang/en_us.json +++ b/common/src/main/resources/assets/pathmind/lang/en_us.json @@ -287,6 +287,10 @@ "pathmind.node.mode.goal_y.desc": "Set goal to specific Y level", "pathmind.node.mode.goto_block": "Go to Block", "pathmind.node.mode.goto_block.desc": "Go to nearest block of specified type", + "pathmind.node.mode.alert_sound": "Play Sound", + "pathmind.node.mode.alert_webhook": "Send Webhook", + "pathmind.node.mode.alert_sound.desc": "Play a sound so you notice while tabbed out or AFK", + "pathmind.node.mode.alert_webhook.desc": "POST the text to the webhook URL set in Pathmind settings", "pathmind.node.mode.goto_xyz": "Go to XYZ", "pathmind.node.mode.goto_xyz.desc": "Go to specific X, Y, Z coordinates", "pathmind.node.mode.goto_xz": "Go to XZ", From 25d5111709ce118ca997738cf5e41f1f562a7abc Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 14:08:30 +0200 Subject: [PATCH 4/5] Let Walk run until something stops it Walk could only run for a fixed duration or distance. "Walk until X" had to be built out of Repeat Until, which re-ran the node every iteration and released the forward key between them, so the player stuttered instead of walking, and a second Walk could not extend the first without ending it. Three changes: - WALK gains modes. Walk For is the existing timed/distance behaviour and stays the default. Start Walking holds forward and completes immediately, the way Sprint already does. Stop Walking releases it. Presets saved before this land with no mode, so they take the constructor default of WALK_FOR, whose parameters have the same names and defaults as the old type parameters and restore unchanged. - Both Walk parameter slots stop being hard requirements. A required slot fails the node outright at execution time (NodeExecutionCoordinator), which is why Walk demanded a Direction even though Look already aims the player, and why an unbounded walk was not expressible. Walk For with neither duration nor distance still completes as a no-op, unchanged. - WalkHold owns the forward key for Walk nodes and counts holds. Previously each Walk wrote client.options.keyUp directly, so any walk finishing released the key out from under every other walk still running. Stop-all releases the sustained hold explicitly, since by design it outlives the node that started it. Known limitation, called out in a comment on WalkHold: this arbitrates Walk against Walk only. NavigatorPrimitiveExecutor drives keyUp as a per-tick servo loop and still overwrites a hold while pathfinding is active. That conflict predates this change; making the navigator a hold participant is a larger patch and belongs on its own. Verified: Fabric compiles on 26.2 and NeoForge on 26.1.2, the mc26 source transform leaves WalkHold byte-identical, and the hold counter's semantics were checked standalone. Not verified locally: :common:test and the 1.21.x targets, which need a JDK 21 toolchain; CI covers both. The editor behaviour, in particular the mode selector on Walk and the now-optional slots, has not been clicked through in a dev client. Co-Authored-By: Claude Opus 5 --- .../pathmind/execution/ExecutionManager.java | 2 + .../java/com/pathmind/nodes/NodeCatalog.java | 10 ++- .../java/com/pathmind/nodes/NodeMode.java | 8 +++ .../nodes/NodeMovementCommandExecutor.java | 32 +++++---- .../java/com/pathmind/nodes/WalkHold.java | 70 ++++++++++++++++++ .../java/com/pathmind/nodes/WalkHoldTest.java | 71 +++++++++++++++++++ 6 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 common/src/main/java/com/pathmind/nodes/WalkHold.java create mode 100644 common/src/test/java/com/pathmind/nodes/WalkHoldTest.java diff --git a/common/src/main/java/com/pathmind/execution/ExecutionManager.java b/common/src/main/java/com/pathmind/execution/ExecutionManager.java index 950e5ed1..fa98e6d2 100644 --- a/common/src/main/java/com/pathmind/execution/ExecutionManager.java +++ b/common/src/main/java/com/pathmind/execution/ExecutionManager.java @@ -1056,6 +1056,8 @@ public void stopExecution() { */ public void requestStopAll() { cancelAllNavigationCommands(); + // A sustained Walk outlives the node that started it, so stop has to end it explicitly. + com.pathmind.nodes.WalkHold.releaseAll(Minecraft.getInstance()); if (!sessionState.isActivelyExecuting() && sessionState.getActiveNode() == null && activeChains.isEmpty()) { runtimeValues.clear(); diff --git a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java index 1474ff11..fca1be2f 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java @@ -732,8 +732,10 @@ public final class NodeCatalog { provided(NodeType.VARIABLE, NodeValueTrait.VARIABLE, NodeValueTrait.ANY); provided(NodeType.ROUTINE_INPUT, NodeValueTrait.ANY); + // Both slots are optional: Look already aims the player, and Start Walking has no + // duration at all. A required slot fails the node outright at execution time. parameterHost(NodeType.WALK, - slot("Direction", true, + slot("Direction", false, NodeValueTrait.DIRECTION, NodeValueTrait.ROTATION, NodeValueTrait.COORDINATE, @@ -742,7 +744,7 @@ public final class NodeCatalog { NodeValueTrait.ENTITY, NodeValueTrait.PLAYER, NodeValueTrait.LIST_ITEM), - slot("Duration/Distance", true, NodeValueTrait.DURATION, NodeValueTrait.DISTANCE)); + slot("Duration/Distance", false, NodeValueTrait.DURATION, NodeValueTrait.DISTANCE)); parameterHost(NodeType.LOOK, NodeValueTrait.ROTATION, NodeValueTrait.DIRECTION, @@ -909,6 +911,10 @@ public final class NodeCatalog { modeParameters(NodeMode.FARM_WAYPOINT, of("Waypoint", ParameterType.STRING, "farm"), of("Range", ParameterType.INTEGER, "10")); + // WALK_START and WALK_STOP deliberately declare no parameters. + modeParameters(NodeMode.WALK_FOR, + of("Duration", ParameterType.DOUBLE, "1.0"), + of("Distance", ParameterType.DOUBLE, "0.0")); modeParameters(NodeMode.ALERT_SOUND, of("Sound", ParameterType.STRING, "minecraft:block.note_block.pling"), of("Volume", ParameterType.DOUBLE, "1.0")); diff --git a/common/src/main/java/com/pathmind/nodes/NodeMode.java b/common/src/main/java/com/pathmind/nodes/NodeMode.java index aedcc0af..e3a8133a 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeMode.java +++ b/common/src/main/java/com/pathmind/nodes/NodeMode.java @@ -8,6 +8,10 @@ * Each mode corresponds to a specific behavior within a generalized node type. */ public enum NodeMode { + // WALK modes + WALK_FOR("Walk For", "Walk for a duration or distance, then continue"), + WALK_START("Start Walking", "Hold forward and continue immediately"), + WALK_STOP("Stop Walking", "Release a walk started by Start Walking"), // ALERT modes ALERT_SOUND("Play Sound", "Play a sound so you notice while tabbed out or AFK"), ALERT_WEBHOOK("Send Webhook", "POST the text to the webhook URL set in Pathmind settings"), @@ -182,6 +186,9 @@ public static NodeMode[] getModesForNodeType(NodeType nodeType) { case ALERT -> new NodeMode[]{ ALERT_SOUND, ALERT_WEBHOOK }; + case WALK -> new NodeMode[]{ + WALK_FOR, WALK_START, WALK_STOP + }; default -> new NodeMode[0]; }; } @@ -207,6 +214,7 @@ public static NodeMode getDefaultModeForNodeType(NodeType nodeType) { case SENSOR_POSITION_OF -> SENSOR_POSITION_XYZ; case SENSOR_LOOK_DIRECTION -> SENSOR_LOOK_ROTATION; case ALERT -> ALERT_SOUND; + case WALK -> WALK_FOR; default -> null; }; } diff --git a/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java b/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java index 93aa7452..883db092 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java +++ b/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java @@ -49,6 +49,22 @@ void executeWalkCommand(CompletableFuture future) { return; } + NodeMode mode = owner.getMode(); + if (mode == NodeMode.WALK_STOP) { + WalkHold.stopSustained(client); + future.complete(null); + return; + } + if (mode == NodeMode.WALK_START) { + client.execute(() -> + owner.orientPlayerTowardsRuntimeTarget(client, owner.runtimeState().runtimeParameterData)); + WalkHold.startSustained(client); + // The point of this mode is that walking outlives the node, so it completes at once + // and the graph moves on to whatever decides when to stop. + future.complete(null); + return; + } + double durationSeconds = Math.max(0.0, owner.getDoubleParameter("Duration", 1.0)); double distance = Math.max(0.0, owner.getDoubleParameter("Distance", 0.0)); boolean useDistance = distance > 0.0; @@ -73,11 +89,10 @@ void executeWalkCommand(CompletableFuture future) { new Thread(() -> { boolean interrupted = false; try { + // Acquired first so the finally below always has a matching hold to release. + WalkHold.acquire(client); NodeClientRuntimeSupport.runOnClientThread(client, () -> { owner.orientPlayerTowardsRuntimeTarget(client, owner.runtimeState().runtimeParameterData); - if (client.options != null && client.options.keyUp != null) { - client.options.keyUp.setDown(true); - } }); if (useDistance) { @@ -143,16 +158,7 @@ void executeWalkCommand(CompletableFuture future) { Thread.currentThread().interrupt(); interrupted = true; } finally { - try { - NodeClientRuntimeSupport.runOnClientThread(client, () -> { - if (client.options != null && client.options.keyUp != null) { - client.options.keyUp.setDown(false); - } - }); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - interrupted = true; - } + WalkHold.release(client); if (interrupted) { future.completeExceptionally(new InterruptedException()); } else { diff --git a/common/src/main/java/com/pathmind/nodes/WalkHold.java b/common/src/main/java/com/pathmind/nodes/WalkHold.java new file mode 100644 index 00000000..09ef2491 --- /dev/null +++ b/common/src/main/java/com/pathmind/nodes/WalkHold.java @@ -0,0 +1,70 @@ +package com.pathmind.nodes; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import net.minecraft.client.Minecraft; + +/** + * Single owner of the forward key for Walk nodes. + * + *

Walk nodes used to write {@code keyUp} directly, so a one-second timed walk finishing + * released the key out from under any walk still running in another chain. Holds are counted + * instead: the key stays down while anyone still wants it. + * + *

ponytail: this arbitrates Walk against Walk only. {@code NavigatorPrimitiveExecutor} + * drives {@code keyUp} as a per-tick servo loop and will still overwrite a hold while + * pathfinding is active; making the navigator a hold participant is a separate change. + */ +public final class WalkHold { + private static final AtomicInteger HOLDS = new AtomicInteger(); + /** The open-ended hold owned by Start Walking, released by Stop Walking. */ + private static final AtomicBoolean SUSTAINED = new AtomicBoolean(); + + private WalkHold() { + } + + public static void acquire(Minecraft client) { + HOLDS.incrementAndGet(); + apply(client, true); + } + + public static void release(Minecraft client) { + if (HOLDS.updateAndGet(current -> current <= 1 ? 0 : current - 1) == 0) { + apply(client, false); + } + } + + public static void startSustained(Minecraft client) { + if (SUSTAINED.compareAndSet(false, true)) { + acquire(client); + } + } + + public static void stopSustained(Minecraft client) { + if (SUSTAINED.compareAndSet(true, false)) { + release(client); + } + } + + /** Stop-all must not leave the player walking into a ravine forever. */ + public static void releaseAll(Minecraft client) { + SUSTAINED.set(false); + HOLDS.set(0); + apply(client, false); + } + + static boolean isHeld() { + return HOLDS.get() > 0; + } + + private static void apply(Minecraft client, boolean down) { + if (client == null) { + return; + } + client.execute(() -> { + if (client.options != null && client.options.keyUp != null) { + client.options.keyUp.setDown(down); + } + }); + } +} diff --git a/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java b/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java new file mode 100644 index 00000000..2aac35cb --- /dev/null +++ b/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java @@ -0,0 +1,71 @@ +package com.pathmind.nodes; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * The hold count is what stops one Walk finishing from cancelling another, so it is the part + * worth pinning down. A null client makes the key write a no-op and leaves the counter visible. + */ +class WalkHoldTest { + + @BeforeEach + void reset() { + WalkHold.releaseAll(null); + } + + @Test + void keyStaysHeldWhileAnyWalkStillWantsIt() { + WalkHold.acquire(null); + WalkHold.acquire(null); + WalkHold.release(null); + assertTrue(WalkHold.isHeld(), "a second walk finishing must not release the first"); + WalkHold.release(null); + assertFalse(WalkHold.isHeld()); + } + + @Test + void timedWalkDoesNotCancelASustainedOne() { + WalkHold.startSustained(null); + WalkHold.acquire(null); + WalkHold.release(null); + assertTrue(WalkHold.isHeld(), "Start Walking must survive a timed walk running alongside it"); + WalkHold.stopSustained(null); + assertFalse(WalkHold.isHeld()); + } + + @Test + void sustainedHoldIsIdempotentInBothDirections() { + WalkHold.startSustained(null); + WalkHold.startSustained(null); + WalkHold.stopSustained(null); + assertFalse(WalkHold.isHeld(), "a second Start Walking must not need a second Stop Walking"); + WalkHold.stopSustained(null); + assertFalse(WalkHold.isHeld()); + } + + @Test + void unbalancedReleaseCannotDriveTheCountNegative() { + // Stop-all zeroes the count while timed walks are still in their finally blocks; those + // late releases must not leave a debt that swallows the next acquire. + WalkHold.acquire(null); + WalkHold.releaseAll(null); + WalkHold.release(null); + WalkHold.release(null); + WalkHold.acquire(null); + assertTrue(WalkHold.isHeld()); + } + + @Test + void walkModesAreRegisteredWithTimedAsTheDefault() { + assertEquals(NodeMode.WALK_FOR, NodeMode.getDefaultModeForNodeType(NodeType.WALK)); + assertEquals(3, NodeMode.getModesForNodeType(NodeType.WALK).length); + // Both slots optional: Look already aims, and Start Walking has no duration. + assertFalse(NodeCatalog.isParameterSlotAlwaysRequired(NodeType.WALK, 0)); + assertFalse(NodeCatalog.isParameterSlotAlwaysRequired(NodeType.WALK, 1)); + } +} From 4fea00eb502aa68b298274f0716f93271fe775d6 Mon Sep 17 00:00:00 2001 From: Gaetarra Date: Sat, 29 Aug 2026 16:23:09 +0200 Subject: [PATCH 5/5] Drop Walk's Direction slot and hide the timer outside Walk For Follow-up to the Walk modes, from testing the editor. - Walk no longer has a Direction slot. Look already aims the player, so the second way to do it only added a slot to fill. Presets saved with the old layout are migrated on load: duration/distance moves from slot 1 to slot 0, and whatever sat in the old slot 0 aimed the player and is dropped. The two orientPlayerTowardsRuntimeTarget calls go with it. - The Duration/Distance slot only exists in Walk For. Start and Stop Walking have nothing to time, and Node.getParameterSlotCount now returns 0 for them, so the slot is not drawn. - Walk is tagged RENDER_INLINE_PARAMETERS. Without it NodeGraph.rendersInline Parameters is false, the parameter strip is never drawn, and the mode selector that lives inside that strip is unreachable, which is what made the modes invisible when they were first added. - Mode display names get lang entries. NodeMode.getDisplayName ignores the strings in the enum constructor and builds "pathmind.node.mode." as a translation key, so the modes rendered as pathmind.node.mode.walk_for. Verified in a dev client: the selector opens, Start/Stop Walking show no slot, Walk For still takes a duration, and walking with an empty Direction works. Co-Authored-By: Claude Opus 5 --- .../com/pathmind/data/NodeGraphPersistence.java | 6 ++++++ .../src/main/java/com/pathmind/nodes/Node.java | 2 ++ .../java/com/pathmind/nodes/NodeCatalog.java | 16 +++++----------- .../nodes/NodeMovementCommandExecutor.java | 11 +++-------- .../resources/assets/pathmind/lang/en_us.json | 6 ++++++ .../java/com/pathmind/nodes/WalkHoldTest.java | 5 +++-- 6 files changed, 25 insertions(+), 21 deletions(-) diff --git a/common/src/main/java/com/pathmind/data/NodeGraphPersistence.java b/common/src/main/java/com/pathmind/data/NodeGraphPersistence.java index aaf5c3a7..badbeac2 100644 --- a/common/src/main/java/com/pathmind/data/NodeGraphPersistence.java +++ b/common/src/main/java/com/pathmind/data/NodeGraphPersistence.java @@ -436,6 +436,12 @@ public static List convertToNodes(NodeGraphData data) { if (parameter != null) { int slot = host.getType() == NodeType.ROUTINE_CALL && !isBlank(attachment.getRoutineInputId()) ? host.getRoutineSlotForInputId(attachment.getRoutineInputId()) : attachment.getSlotIndex(); + if (host.getType() == NodeType.WALK) { + // Walk dropped its Direction slot, so duration/distance moved from + // slot 1 to slot 0. Anything that was in the old slot 0 aimed the + // player and has nowhere to go. + slot = slot == 1 ? 0 : -1; + } if (slot >= 0) host.attachParameter(parameter, slot); } } diff --git a/common/src/main/java/com/pathmind/nodes/Node.java b/common/src/main/java/com/pathmind/nodes/Node.java index 5fc55e98..ff4793d2 100644 --- a/common/src/main/java/com/pathmind/nodes/Node.java +++ b/common/src/main/java/com/pathmind/nodes/Node.java @@ -1165,6 +1165,8 @@ public int getParameterSlotCount() { return Math.max(2, dynamicBooleanOperatorSlotCount); } if (type == NodeType.ROUTINE_CALL) return routineMetadata.getRoutineArgumentCount(); + // Start/Stop Walking have nothing to time, so they show no slot at all. + if (type == NodeType.WALK && mode != NodeMode.WALK_FOR) return 0; return NodeTraitRegistry.getParameterSlotCount(type); } diff --git a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java index fca1be2f..e34c8f2a 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeCatalog.java +++ b/common/src/main/java/com/pathmind/nodes/NodeCatalog.java @@ -445,6 +445,7 @@ public final class NodeCatalog { // inside that strip (NodeRenderer.renderInlineParameterContent). A node with modes but // without this tag has no way to reach them in the editor. tag(NodeFlag.RENDER_INLINE_PARAMETERS, + NodeType.WALK, NodeType.ALERT, NodeType.UI_UTILS, NodeType.SENSOR_FABRIC_EVENT, @@ -732,18 +733,11 @@ public final class NodeCatalog { provided(NodeType.VARIABLE, NodeValueTrait.VARIABLE, NodeValueTrait.ANY); provided(NodeType.ROUTINE_INPUT, NodeValueTrait.ANY); - // Both slots are optional: Look already aims the player, and Start Walking has no - // duration at all. A required slot fails the node outright at execution time. + // Walk has no Direction slot: Look already aims the player, and carrying a second way + // to do it made the node reject runs that had only one of the two filled in. The slot + // is optional because Start Walking has no duration at all, and a required slot fails + // the node outright at execution time. parameterHost(NodeType.WALK, - slot("Direction", false, - NodeValueTrait.DIRECTION, - NodeValueTrait.ROTATION, - NodeValueTrait.COORDINATE, - NodeValueTrait.BLOCK, - NodeValueTrait.ITEM, - NodeValueTrait.ENTITY, - NodeValueTrait.PLAYER, - NodeValueTrait.LIST_ITEM), slot("Duration/Distance", false, NodeValueTrait.DURATION, NodeValueTrait.DISTANCE)); parameterHost(NodeType.LOOK, NodeValueTrait.ROTATION, diff --git a/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java b/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java index 883db092..7e787508 100644 --- a/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java +++ b/common/src/main/java/com/pathmind/nodes/NodeMovementCommandExecutor.java @@ -40,7 +40,7 @@ void executeLookCommand(CompletableFuture future) { } void executeWalkCommand(CompletableFuture future) { - if (owner.preprocessAttachedParameter(EnumSet.of(Node.ParameterUsage.LOOK_ORIENTATION), future) == Node.ParameterHandlingResult.COMPLETE) { + if (owner.preprocessAttachedParameter(EnumSet.noneOf(Node.ParameterUsage.class), future) == Node.ParameterHandlingResult.COMPLETE) { return; } net.minecraft.client.Minecraft client = net.minecraft.client.Minecraft.getInstance(); @@ -56,8 +56,6 @@ void executeWalkCommand(CompletableFuture future) { return; } if (mode == NodeMode.WALK_START) { - client.execute(() -> - owner.orientPlayerTowardsRuntimeTarget(client, owner.runtimeState().runtimeParameterData)); WalkHold.startSustained(client); // The point of this mode is that walking outlives the node, so it completes at once // and the graph moves on to whatever decides when to stop. @@ -71,9 +69,9 @@ void executeWalkCommand(CompletableFuture future) { NodeParameter durationParameter = owner.getParameter("Duration"); boolean durationExplicitlyEdited = durationParameter != null && durationParameter.isUserEdited(); - Node slotOneParameter = owner.getAttachedParameter(1); + Node slotOneParameter = owner.getAttachedParameter(0); if (slotOneParameter != null && slotOneParameter.getType() == NodeType.VARIABLE) { - Node resolved = owner.resolveVariableValueNode(slotOneParameter, 1, null); + Node resolved = owner.resolveVariableValueNode(slotOneParameter, 0, null); if (resolved != null) { slotOneParameter = resolved; } @@ -91,9 +89,6 @@ void executeWalkCommand(CompletableFuture future) { try { // Acquired first so the finally below always has a matching hold to release. WalkHold.acquire(client); - NodeClientRuntimeSupport.runOnClientThread(client, () -> { - owner.orientPlayerTowardsRuntimeTarget(client, owner.runtimeState().runtimeParameterData); - }); if (useDistance) { net.minecraft.core.BlockPos startBlockPos = NodeClientRuntimeSupport.supplyFromClient(client, diff --git a/common/src/main/resources/assets/pathmind/lang/en_us.json b/common/src/main/resources/assets/pathmind/lang/en_us.json index 4af4bde6..b0dbd6a2 100644 --- a/common/src/main/resources/assets/pathmind/lang/en_us.json +++ b/common/src/main/resources/assets/pathmind/lang/en_us.json @@ -287,6 +287,12 @@ "pathmind.node.mode.goal_y.desc": "Set goal to specific Y level", "pathmind.node.mode.goto_block": "Go to Block", "pathmind.node.mode.goto_block.desc": "Go to nearest block of specified type", + "pathmind.node.mode.walk_for": "Walk For", + "pathmind.node.mode.walk_for.desc": "Walk for a duration or distance, then continue", + "pathmind.node.mode.walk_start": "Start Walking", + "pathmind.node.mode.walk_start.desc": "Hold forward and continue immediately", + "pathmind.node.mode.walk_stop": "Stop Walking", + "pathmind.node.mode.walk_stop.desc": "Release a walk started by Start Walking", "pathmind.node.mode.alert_sound": "Play Sound", "pathmind.node.mode.alert_webhook": "Send Webhook", "pathmind.node.mode.alert_sound.desc": "Play a sound so you notice while tabbed out or AFK", diff --git a/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java b/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java index 2aac35cb..24a4f186 100644 --- a/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java +++ b/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java @@ -64,8 +64,9 @@ void unbalancedReleaseCannotDriveTheCountNegative() { void walkModesAreRegisteredWithTimedAsTheDefault() { assertEquals(NodeMode.WALK_FOR, NodeMode.getDefaultModeForNodeType(NodeType.WALK)); assertEquals(3, NodeMode.getModesForNodeType(NodeType.WALK).length); - // Both slots optional: Look already aims, and Start Walking has no duration. + // One slot, and it is optional: Look already aims the player, and Start Walking has + // no duration. A required slot fails the node outright before it runs. + assertEquals(1, NodeTraitRegistry.getParameterSlotCount(NodeType.WALK)); assertFalse(NodeCatalog.isParameterSlotAlwaysRequired(NodeType.WALK, 0)); - assertFalse(NodeCatalog.isParameterSlotAlwaysRequired(NodeType.WALK, 1)); } }