Skip to content

Commit 7c74808

Browse files
committed
feat: add entity listing/goto workflow updates and bridge end command
1 parent fa64063 commit 7c74808

17 files changed

Lines changed: 1603 additions & 31 deletions

File tree

AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Repository Agent Rules
2+
3+
## Chat Context Maintenance (Required)
4+
For every meaningful task in this repository:
5+
1. Append an entry to `chat-context/UPDATES.md`.
6+
2. Include:
7+
- date
8+
- short request summary
9+
- what changed (files/APIs/behavior)
10+
- validation commands run
11+
- notable follow-up note/risk (if any)
12+
13+
## When To Also Update `chat-context/README.md`
14+
Update `chat-context/README.md` if the task changes:
15+
1. architecture or protocol surface,
16+
2. public API shape/usage guidance,
17+
3. key operational workflows (start/stop/debug behavior),
18+
4. important project-orientation context future chats should know first.
19+
20+
## Startup Read Order
21+
1. `chat-context/README.md`
22+
2. `chat-context/UPDATES.md`
23+
3. Then proceed to task-specific files.

mod/src/main/java/com/pyritone/bridge/PyritoneBridgeClientMod.java

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.pyritone.bridge;
22

33
import com.google.gson.JsonElement;
4+
import com.google.gson.JsonArray;
45
import com.google.gson.JsonObject;
56
import com.pyritone.bridge.command.PyritoneCommand;
67
import com.pyritone.bridge.config.BridgeConfig;
@@ -9,6 +10,7 @@
910
import com.pyritone.bridge.net.ProtocolCodec;
1011
import com.pyritone.bridge.net.WebSocketBridgeServer;
1112
import com.pyritone.bridge.runtime.BaritoneGateway;
13+
import com.pyritone.bridge.runtime.EntityTypeSelector;
1214
import com.pyritone.bridge.runtime.StatusSubscriptionRegistry;
1315
import com.pyritone.bridge.runtime.TaskRegistry;
1416
import com.pyritone.bridge.runtime.TaskLifecycleResolver;
@@ -22,6 +24,8 @@
2224
import net.fabricmc.fabric.api.client.message.v1.ClientSendMessageEvents;
2325
import net.fabricmc.loader.api.FabricLoader;
2426
import net.minecraft.client.MinecraftClient;
27+
import net.minecraft.entity.Entity;
28+
import net.minecraft.registry.Registries;
2529
import net.minecraft.text.MutableText;
2630
import net.minecraft.text.Text;
2731
import net.minecraft.util.Formatting;
@@ -35,7 +39,10 @@
3539
import java.nio.file.StandardOpenOption;
3640
import java.security.MessageDigest;
3741
import java.time.Instant;
42+
import java.util.ArrayList;
43+
import java.util.Comparator;
3844
import java.util.List;
45+
import java.util.Locale;
3946
import java.util.Optional;
4047
import java.util.Set;
4148
import java.util.concurrent.CompletionException;
@@ -76,7 +83,7 @@ public void onInitializeClient() {
7683

7784
ClientTickEvents.END_CLIENT_TICK.register(client -> {
7885
baritoneGateway.tickRegisterPathListener();
79-
baritoneGateway.tickRegisterPyritoneHashCommand(this::forceCancelActiveTaskFromPyritoneCommand);
86+
baritoneGateway.tickRegisterPyritoneHashCommand(() -> endPythonSessionsFromPyritoneCommand());
8087
baritoneGateway.tickApplyPyritoneChatBranding();
8188
tickTaskLifecycle();
8289
tickStatusStreams();
@@ -132,8 +139,8 @@ private void ensureBaritoneSettingsFile() {
132139
}
133140
}
134141
private boolean handleOutgoingChat(String message) {
135-
if (message != null && message.trim().equalsIgnoreCase("#pyritone cancel")) {
136-
forceCancelActiveTaskFromPyritoneCommand();
142+
if (message != null && message.trim().equalsIgnoreCase("#pyritone end")) {
143+
endPythonSessionsFromPyritoneCommand();
137144
return false;
138145
}
139146
emitWatchMatches("chat", message);
@@ -216,6 +223,7 @@ private JsonObject handleRequest(WebSocketBridgeServer.ClientSession session, Js
216223
case "api.metadata.get" -> handleApiMetadataGet(id, params, session);
217224
case "api.construct" -> handleApiConstruct(id, params, session);
218225
case "api.invoke" -> handleApiInvoke(id, params, session);
226+
case "entities.list" -> handleEntitiesList(id, params);
219227
case "baritone.execute" -> handleBaritoneExecute(id, params);
220228
case "task.cancel" -> handleTaskCancel(id);
221229
default -> ProtocolCodec.errorResponse(id, "METHOD_NOT_FOUND", "Unknown method: " + method);
@@ -304,6 +312,62 @@ private JsonObject handleApiInvoke(String id, JsonObject params, WebSocketBridge
304312
return handleTypedApiRequest(id, () -> typedApiService.invoke(session.sessionId(), params));
305313
}
306314

315+
private JsonObject handleEntitiesList(String id, JsonObject params) {
316+
try {
317+
JsonObject result = runOnClientThread(() -> {
318+
MinecraftClient client = MinecraftClient.getInstance();
319+
if (client == null || client.world == null || client.player == null) {
320+
throw new TypedApiException("NOT_IN_WORLD", "Join a world before listing entities");
321+
}
322+
323+
EntityTypeSelector selector = EntityTypeSelector.fromParams(params);
324+
325+
List<JsonObject> entities = new ArrayList<>();
326+
for (Entity entity : client.world.getEntities()) {
327+
if (entity == client.player) {
328+
continue;
329+
}
330+
331+
String typeId = Registries.ENTITY_TYPE.getId(entity.getType()).toString();
332+
if (!selector.matches(entity, typeId)) {
333+
continue;
334+
}
335+
336+
JsonObject payload = new JsonObject();
337+
payload.addProperty("id", entity.getUuidAsString());
338+
payload.addProperty("type_id", typeId);
339+
payload.addProperty("category", entity.getType().getSpawnGroup().name().toLowerCase(Locale.ROOT));
340+
payload.addProperty("x", entity.getX());
341+
payload.addProperty("y", entity.getY());
342+
payload.addProperty("z", entity.getZ());
343+
payload.addProperty("distance_sq", client.player.squaredDistanceTo(entity));
344+
entities.add(payload);
345+
}
346+
347+
entities.sort(Comparator.comparingDouble(entity -> entity.get("distance_sq").getAsDouble()));
348+
349+
JsonArray entries = new JsonArray();
350+
for (JsonObject entity : entities) {
351+
entries.add(entity);
352+
}
353+
354+
JsonObject response = new JsonObject();
355+
response.add("entities", entries);
356+
return response;
357+
});
358+
return ProtocolCodec.successResponse(id, result);
359+
} catch (IllegalArgumentException exception) {
360+
return ProtocolCodec.errorResponse(id, "BAD_REQUEST", exception.getMessage());
361+
} catch (TypedApiException exception) {
362+
return ProtocolCodec.errorResponse(id, exception.code(), exception.getMessage(), exception.details());
363+
} catch (TimeoutException exception) {
364+
return ProtocolCodec.errorResponse(id, "INTERNAL_ERROR", "Timed out waiting for client thread");
365+
} catch (Exception exception) {
366+
LOGGER.debug("entities.list request failed", exception);
367+
return ProtocolCodec.errorResponse(id, "INTERNAL_ERROR", "Unable to list entities");
368+
}
369+
}
370+
307371
private JsonObject buildStatusPayload(WebSocketBridgeServer.ClientSession session) {
308372
JsonObject result = new JsonObject();
309373
result.addProperty("protocol_version", BridgeConfig.PROTOCOL_VERSION);
@@ -328,7 +392,9 @@ private JsonObject handleBaritoneExecute(String id, JsonObject params) {
328392
return ProtocolCodec.errorResponse(id, "BAD_REQUEST", "Missing command");
329393
}
330394

331-
emitPyritoneNotice("Python execute: " + compactCommand(command));
395+
String label = asString(params, "label");
396+
String notice = label != null && !label.isBlank() ? label : command;
397+
emitPyritoneNotice("Python execute: " + compactCommand(notice));
332398

333399
if (!baritoneGateway.isAvailable()) {
334400
emitPyritoneNotice("Python execute blocked: Baritone unavailable");
@@ -447,6 +513,31 @@ private Object resolvePrimaryBaritone() throws ReflectiveOperationException {
447513
return baritoneGateway.resolvePrimaryBaritoneForTypedApi();
448514
}
449515

516+
public int endPythonSessionsFromPyritoneCommand() {
517+
WebSocketBridgeServer currentServer = this.server;
518+
if (currentServer == null || !currentServer.isRunning()) {
519+
emitPyritoneNotice("Bridge is not running");
520+
return 0;
521+
}
522+
523+
int disconnected = 0;
524+
for (WebSocketBridgeServer.ClientSession session : currentServer.sessionSnapshot()) {
525+
if (!session.isAuthenticated()) {
526+
continue;
527+
}
528+
session.close();
529+
disconnected += 1;
530+
}
531+
532+
if (disconnected > 0) {
533+
emitPyritoneNotice("Ended " + disconnected + " Python websocket session(s)");
534+
} else {
535+
emitPyritoneNotice("No authenticated Python websocket sessions");
536+
}
537+
538+
return disconnected;
539+
}
540+
450541
public boolean forceCancelActiveTaskFromPyritoneCommand() {
451542
Optional<TaskSnapshot> active = taskRegistry.active();
452543
if (active.isEmpty()) {

mod/src/main/java/com/pyritone/bridge/command/PyritoneCommand.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ public static void register(PyritoneBridgeClientMod mod) {
1919
context.getSource().sendFeedback(Text.literal(mod.commandStatusLine()));
2020
return 1;
2121
}))
22-
.then(ClientCommandManager.literal("cancel")
22+
.then(ClientCommandManager.literal("end")
2323
.executes(context -> {
24-
boolean canceled = mod.forceCancelActiveTaskFromPyritoneCommand();
25-
if (canceled) {
26-
context.getSource().sendFeedback(Text.literal("Requested hard cancel for active Py-Ritone task"));
24+
int disconnected = mod.endPythonSessionsFromPyritoneCommand();
25+
if (disconnected > 0) {
26+
context.getSource().sendFeedback(Text.literal("Ended " + disconnected + " Python websocket session(s)"));
2727
} else {
28-
context.getSource().sendFeedback(Text.literal("No active Py-Ritone task"));
28+
context.getSource().sendFeedback(Text.literal("No authenticated Python websocket sessions"));
2929
}
3030
return 1;
3131
}))

mod/src/main/java/com/pyritone/bridge/runtime/BaritoneGateway.java

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,9 @@ public void tickRegisterPathListener() {
6262
}, false);
6363
}
6464

65-
public void tickRegisterPyritoneHashCommand(Runnable cancelAction) {
65+
public void tickRegisterPyritoneHashCommand(Runnable endAction) {
6666
onClientThread(() -> {
67-
ensurePyritoneHashCommandOnClientThread(cancelAction);
67+
ensurePyritoneHashCommandOnClientThread(endAction);
6868
return true;
6969
}, false);
7070
}
@@ -151,8 +151,8 @@ private void ensurePathListenerOnClientThread() {
151151
}
152152
}
153153

154-
private void ensurePyritoneHashCommandOnClientThread(Runnable cancelAction) {
155-
if (cancelAction == null) {
154+
private void ensurePyritoneHashCommandOnClientThread(Runnable endAction) {
155+
if (endAction == null) {
156156
return;
157157
}
158158

@@ -181,7 +181,7 @@ private void ensurePyritoneHashCommandOnClientThread(Runnable cancelAction) {
181181

182182
ClassLoader loader = getClass().getClassLoader();
183183
Class<?> iCommand = Class.forName("baritone.api.command.ICommand", true, loader);
184-
InvocationHandler handler = (proxy, method, args) -> handlePyritoneCommandCall(proxy, method, args, cancelAction);
184+
InvocationHandler handler = (proxy, method, args) -> handlePyritoneCommandCall(proxy, method, args, endAction);
185185
Object commandProxy = Proxy.newProxyInstance(loader, new Class<?>[]{iCommand}, handler);
186186

187187
Object registered = invoke(registry, "register", new Class<?>[]{Object.class}, new Object[]{commandProxy});
@@ -395,30 +395,30 @@ private Object handleEventListenerCall(Object proxy, Method method, Object[] arg
395395
return defaultValue(method.getReturnType());
396396
}
397397

398-
private Object handlePyritoneCommandCall(Object proxy, Method method, Object[] args, Runnable cancelAction) {
398+
private Object handlePyritoneCommandCall(Object proxy, Method method, Object[] args, Runnable endAction) {
399399
String methodName = method.getName();
400400
switch (methodName) {
401401
case "execute" -> {
402402
String subcommand = extractFirstArg(args);
403-
if ("cancel".equals(subcommand)) {
404-
cancelAction.run();
403+
if ("end".equals(subcommand)) {
404+
endAction.run();
405405
}
406406
return null;
407407
}
408408
case "tabComplete" -> {
409409
String prefix = extractFirstArg(args);
410410
if (prefix.isBlank()) {
411-
return Stream.of("cancel");
411+
return Stream.of("end");
412412
}
413-
return Stream.of("cancel").filter(option -> option.startsWith(prefix));
413+
return Stream.of("end").filter(option -> option.startsWith(prefix));
414414
}
415415
case "getShortDesc" -> {
416416
return "Py-Ritone controls";
417417
}
418418
case "getLongDesc" -> {
419419
return List.of(
420420
"Py-Ritone command bridge",
421-
"Usage: #pyritone cancel"
421+
"Usage: #pyritone end"
422422
);
423423
}
424424
case "getNames" -> {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.pyritone.bridge.runtime;
2+
3+
import com.google.gson.JsonArray;
4+
import com.google.gson.JsonElement;
5+
import com.google.gson.JsonObject;
6+
import net.minecraft.entity.Entity;
7+
import net.minecraft.entity.mob.MobEntity;
8+
import net.minecraft.entity.player.PlayerEntity;
9+
10+
import java.util.HashSet;
11+
import java.util.Objects;
12+
import java.util.Set;
13+
import java.util.regex.Pattern;
14+
15+
public final class EntityTypeSelector {
16+
public static final String GROUP_PLAYERS = "group:players";
17+
public static final String GROUP_MOBS = "group:mobs";
18+
19+
private static final Pattern ENTITY_ID_PATTERN = Pattern.compile("^[a-z0-9_.-]+:[a-z0-9_./-]+$");
20+
21+
private final boolean includePlayers;
22+
private final boolean includeMobs;
23+
private final Set<String> explicitTypeIds;
24+
25+
private EntityTypeSelector(boolean includePlayers, boolean includeMobs, Set<String> explicitTypeIds) {
26+
this.includePlayers = includePlayers;
27+
this.includeMobs = includeMobs;
28+
this.explicitTypeIds = Set.copyOf(explicitTypeIds);
29+
}
30+
31+
public static EntityTypeSelector fromParams(JsonObject params) {
32+
if (params == null || !params.has("types") || params.get("types").isJsonNull()) {
33+
return allowAll();
34+
}
35+
36+
JsonElement typesElement = params.get("types");
37+
if (!typesElement.isJsonArray()) {
38+
throw new IllegalArgumentException("entities.list params.types must be an array of strings");
39+
}
40+
41+
JsonArray typeArray = typesElement.getAsJsonArray();
42+
boolean includePlayers = false;
43+
boolean includeMobs = false;
44+
Set<String> explicitTypeIds = new HashSet<>();
45+
46+
for (int index = 0; index < typeArray.size(); index += 1) {
47+
JsonElement entry = typeArray.get(index);
48+
if (!entry.isJsonPrimitive() || !entry.getAsJsonPrimitive().isString()) {
49+
throw new IllegalArgumentException("entities.list params.types entries must be strings");
50+
}
51+
52+
String rawValue = entry.getAsString();
53+
String value = rawValue == null ? "" : rawValue.trim();
54+
if (value.isEmpty()) {
55+
throw new IllegalArgumentException("entities.list params.types entries must be non-empty strings");
56+
}
57+
58+
if (value.startsWith("group:")) {
59+
switch (value) {
60+
case GROUP_PLAYERS -> includePlayers = true;
61+
case GROUP_MOBS -> includeMobs = true;
62+
default -> throw new IllegalArgumentException(
63+
"Unknown entities.list group token: "
64+
+ value
65+
+ " (supported: "
66+
+ GROUP_PLAYERS
67+
+ ", "
68+
+ GROUP_MOBS
69+
+ ")"
70+
);
71+
}
72+
continue;
73+
}
74+
75+
if (!ENTITY_ID_PATTERN.matcher(value).matches()) {
76+
throw new IllegalArgumentException(
77+
"Invalid entities.list types entry at index "
78+
+ index
79+
+ ": expected entity id (namespace:path) or group token"
80+
);
81+
}
82+
83+
explicitTypeIds.add(value);
84+
}
85+
86+
return new EntityTypeSelector(includePlayers, includeMobs, explicitTypeIds);
87+
}
88+
89+
public boolean matches(Entity entity, String typeId) {
90+
Objects.requireNonNull(entity, "entity");
91+
return matches(typeId, entity instanceof PlayerEntity, entity instanceof MobEntity);
92+
}
93+
94+
boolean matches(String typeId, boolean isPlayer, boolean isMob) {
95+
if (!hasFilters()) {
96+
return true;
97+
}
98+
if (includePlayers && isPlayer) {
99+
return true;
100+
}
101+
if (includeMobs && isMob) {
102+
return true;
103+
}
104+
return typeId != null && explicitTypeIds.contains(typeId);
105+
}
106+
107+
private boolean hasFilters() {
108+
return includePlayers || includeMobs || !explicitTypeIds.isEmpty();
109+
}
110+
111+
private static EntityTypeSelector allowAll() {
112+
return new EntityTypeSelector(false, false, Set.of());
113+
}
114+
}

0 commit comments

Comments
 (0)