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/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/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/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/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..e34c8f2a 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, @@ -440,7 +441,12 @@ 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.WALK, + NodeType.ALERT, NodeType.UI_UTILS, NodeType.SENSOR_FABRIC_EVENT, NodeType.SENSOR_ATTRIBUTE_DETECTION, @@ -555,6 +561,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); @@ -726,17 +733,12 @@ public final class NodeCatalog { provided(NodeType.VARIABLE, NodeValueTrait.VARIABLE, NodeValueTrait.ANY); provided(NodeType.ROUTINE_INPUT, NodeValueTrait.ANY); + // 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", true, - NodeValueTrait.DIRECTION, - NodeValueTrait.ROTATION, - NodeValueTrait.COORDINATE, - NodeValueTrait.BLOCK, - NodeValueTrait.ITEM, - 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, @@ -903,6 +905,13 @@ 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")); 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 +1165,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 +1581,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 +1733,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 +1883,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 +2113,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..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,13 @@ * 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"), // 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 +183,12 @@ 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 + }; + case WALK -> new NodeMode[]{ + WALK_FOR, WALK_START, WALK_STOP + }; default -> new NodeMode[0]; }; } @@ -200,6 +213,8 @@ 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; + 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..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(); @@ -49,15 +49,29 @@ 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) { + 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; 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; } @@ -73,12 +87,8 @@ void executeWalkCommand(CompletableFuture future) { new Thread(() -> { boolean interrupted = false; try { - NodeClientRuntimeSupport.runOnClientThread(client, () -> { - owner.orientPlayerTowardsRuntimeTarget(client, owner.runtimeState().runtimeParameterData); - if (client.options != null && client.options.keyUp != null) { - client.options.keyUp.setDown(true); - } - }); + // Acquired first so the finally below always has a matching hold to release. + WalkHold.acquire(client); if (useDistance) { net.minecraft.core.BlockPos startBlockPos = NodeClientRuntimeSupport.supplyFromClient(client, @@ -143,16 +153,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/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/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/main/resources/assets/pathmind/lang/en_us.json b/common/src/main/resources/assets/pathmind/lang/en_us.json index a12e7843..b0dbd6a2 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.", @@ -285,6 +287,16 @@ "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", + "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", @@ -563,6 +575,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..140f0cbf --- /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.getModesForNodeType(NodeType.ALERT).length); + } +} 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..24a4f186 --- /dev/null +++ b/common/src/test/java/com/pathmind/nodes/WalkHoldTest.java @@ -0,0 +1,72 @@ +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); + // 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)); + } +}