Skip to content

Commit 632ac6a

Browse files
committed
Implement pause-safe bridge gating and pause-state logging
1 parent 8fc31a2 commit 632ac6a

16 files changed

Lines changed: 3050 additions & 60 deletions

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

Lines changed: 280 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@
5353

5454
public final class PyritoneBridgeClientMod implements ClientModInitializer {
5555
private static final Logger LOGGER = LoggerFactory.getLogger(BridgeConfig.MOD_ID);
56+
private static final Set<String> PAUSE_GATED_METHODS = Set.of(
57+
"status.get",
58+
"status.subscribe",
59+
"status.unsubscribe",
60+
"entities.list",
61+
"api.metadata.get",
62+
"api.construct",
63+
"api.invoke",
64+
"baritone.execute",
65+
"task.cancel"
66+
);
5667

5768
private String token;
5869
private String serverVersion;
@@ -63,6 +74,12 @@ public final class PyritoneBridgeClientMod implements ClientModInitializer {
6374
private final StatusSubscriptionRegistry statusSubscriptionRegistry = new StatusSubscriptionRegistry();
6475
private final TypedApiService typedApiService = new TypedApiService(PyritoneBridgeClientMod.class.getClassLoader());
6576

77+
private final Object pauseStateLock = new Object();
78+
private volatile boolean operatorPauseActive;
79+
private volatile boolean gamePauseActive;
80+
private volatile boolean effectivePauseActive;
81+
private volatile long pauseStateSeq;
82+
6683
private BaritoneGateway baritoneGateway;
6784
private WebSocketBridgeServer server;
6885

@@ -83,8 +100,13 @@ public void onInitializeClient() {
83100

84101
ClientTickEvents.END_CLIENT_TICK.register(client -> {
85102
baritoneGateway.tickRegisterPathListener();
86-
baritoneGateway.tickRegisterPyritoneHashCommand(() -> endPythonSessionsFromPyritoneCommand());
103+
baritoneGateway.tickRegisterPyritoneHashCommand(
104+
() -> endPythonSessionsFromPyritoneCommand(),
105+
() -> pausePythonExecuteFromPyritoneCommand(),
106+
() -> resumePythonExecuteFromPyritoneCommand()
107+
);
87108
baritoneGateway.tickApplyPyritoneChatBranding();
109+
tickPauseState(client);
88110
tickTaskLifecycle();
89111
tickStatusStreams();
90112
});
@@ -139,8 +161,7 @@ private void ensureBaritoneSettingsFile() {
139161
}
140162
}
141163
private boolean handleOutgoingChat(String message) {
142-
if (message != null && message.trim().equalsIgnoreCase("#pyritone end")) {
143-
endPythonSessionsFromPyritoneCommand();
164+
if (applyPyritoneChatControl(message)) {
144165
return false;
145166
}
146167
emitWatchMatches("chat", message);
@@ -189,6 +210,7 @@ private void startBridgeServer() {
189210
}
190211

191212
private void shutdownBridgeServer() {
213+
clearPauseStateForShutdown();
192214
statusSubscriptionRegistry.clear();
193215
typedApiService.clear();
194216
if (this.server != null) {
@@ -214,6 +236,13 @@ private JsonObject handleRequest(WebSocketBridgeServer.ClientSession session, Js
214236
return ProtocolCodec.errorResponse(id, "UNAUTHORIZED", "Authenticate with auth.login first");
215237
}
216238

239+
if (isPauseGatedMethod(method)) {
240+
JsonObject pauseError = pauseGateError(id);
241+
if (pauseError != null) {
242+
return pauseError;
243+
}
244+
}
245+
217246
return switch (method) {
218247
case "auth.login" -> handleAuthLogin(id, params, session);
219248
case "ping" -> handlePing(id, session);
@@ -224,7 +253,7 @@ private JsonObject handleRequest(WebSocketBridgeServer.ClientSession session, Js
224253
case "api.construct" -> handleApiConstruct(id, params, session);
225254
case "api.invoke" -> handleApiInvoke(id, params, session);
226255
case "entities.list" -> handleEntitiesList(id, params);
227-
case "baritone.execute" -> handleBaritoneExecute(id, params);
256+
case "baritone.execute" -> handleBaritoneExecute(id, params, session);
228257
case "task.cancel" -> handleTaskCancel(id);
229258
default -> ProtocolCodec.errorResponse(id, "METHOD_NOT_FOUND", "Unknown method: " + method);
230259
};
@@ -244,8 +273,14 @@ private JsonObject handleAuthLogin(String id, JsonObject params, WebSocketBridge
244273
return ProtocolCodec.errorResponse(id, "UNAUTHORIZED", "Invalid token");
245274
}
246275

276+
if (hasAnotherAuthenticatedSession(session)) {
277+
return ProtocolCodec.errorResponse(id, "UNAUTHORIZED", "Another Python session is already connected");
278+
}
279+
247280
session.setAuthenticated(true);
248281

282+
emitPauseStateEventToSession(session, currentPauseStateSnapshot());
283+
249284
JsonObject result = new JsonObject();
250285
result.addProperty("protocol_version", BridgeConfig.PROTOCOL_VERSION);
251286
result.addProperty("server_version", serverVersion);
@@ -386,7 +421,7 @@ private static String statusDigest(JsonObject status) {
386421
return ProtocolCodec.toLine(status);
387422
}
388423

389-
private JsonObject handleBaritoneExecute(String id, JsonObject params) {
424+
private JsonObject handleBaritoneExecute(String id, JsonObject params, WebSocketBridgeServer.ClientSession session) {
390425
String command = asString(params, "command");
391426
if (command == null || command.isBlank()) {
392427
return ProtocolCodec.errorResponse(id, "BAD_REQUEST", "Missing command");
@@ -514,6 +549,8 @@ private Object resolvePrimaryBaritone() throws ReflectiveOperationException {
514549
}
515550

516551
public int endPythonSessionsFromPyritoneCommand() {
552+
clearOperatorPauseStateFromControl();
553+
517554
WebSocketBridgeServer currentServer = this.server;
518555
if (currentServer == null || !currentServer.isRunning()) {
519556
emitPyritoneNotice("Bridge is not running");
@@ -538,6 +575,28 @@ public int endPythonSessionsFromPyritoneCommand() {
538575
return disconnected;
539576
}
540577

578+
public boolean pausePythonExecuteFromPyritoneCommand() {
579+
boolean changed = setOperatorPauseActive(true);
580+
581+
if (changed) {
582+
emitPyritoneNotice("Paused Python bridge request dispatch");
583+
} else {
584+
emitPyritoneNotice("Python bridge request dispatch is already paused");
585+
}
586+
return changed;
587+
}
588+
589+
public boolean resumePythonExecuteFromPyritoneCommand() {
590+
boolean changed = setOperatorPauseActive(false);
591+
592+
if (changed) {
593+
emitPyritoneNotice("Resumed Python bridge request dispatch");
594+
} else {
595+
emitPyritoneNotice("Python bridge request dispatch was not paused");
596+
}
597+
return changed;
598+
}
599+
541600
public boolean forceCancelActiveTaskFromPyritoneCommand() {
542601
Optional<TaskSnapshot> active = taskRegistry.active();
543602
if (active.isEmpty()) {
@@ -584,6 +643,11 @@ private void onPathEvent(String pathEventName) {
584643
taskLifecycleResolver.recordPathEvent(current.taskId(), pathEventName);
585644
}
586645

646+
private void tickPauseState(MinecraftClient client) {
647+
boolean paused = client != null && client.isPaused();
648+
setGamePauseActive(paused);
649+
}
650+
587651
private void tickTaskLifecycle() {
588652
Optional<TaskSnapshot> active = taskRegistry.active();
589653
if (active.isEmpty()) {
@@ -732,6 +796,208 @@ private void publishEvent(String eventName, JsonObject data) {
732796
currentServer.publishEvent(ProtocolCodec.eventEnvelope(eventName, data));
733797
}
734798

799+
private boolean applyPyritoneChatControl(String message) {
800+
String subcommand = pyritoneSubcommand(message);
801+
if (subcommand == null) {
802+
return false;
803+
}
804+
805+
return switch (subcommand) {
806+
case "end" -> {
807+
endPythonSessionsFromPyritoneCommand();
808+
yield true;
809+
}
810+
case "pause" -> {
811+
pausePythonExecuteFromPyritoneCommand();
812+
yield true;
813+
}
814+
case "resume" -> {
815+
resumePythonExecuteFromPyritoneCommand();
816+
yield true;
817+
}
818+
default -> false;
819+
};
820+
}
821+
822+
private static String pyritoneSubcommand(String message) {
823+
if (message == null) {
824+
return null;
825+
}
826+
String trimmed = message.trim();
827+
if (trimmed.isBlank()) {
828+
return null;
829+
}
830+
String normalized = trimmed.toLowerCase(Locale.ROOT);
831+
if (!normalized.startsWith("#pyritone")) {
832+
return null;
833+
}
834+
String rest = normalized.substring("#pyritone".length()).trim();
835+
if (rest.isBlank()) {
836+
return "";
837+
}
838+
int split = rest.indexOf(' ');
839+
return split >= 0 ? rest.substring(0, split) : rest;
840+
}
841+
842+
private boolean isPauseGatedMethod(String method) {
843+
return PAUSE_GATED_METHODS.contains(method);
844+
}
845+
846+
private JsonObject pauseGateError(String id) {
847+
PauseStateSnapshot snapshot = currentPauseStateSnapshot();
848+
if (!snapshot.paused()) {
849+
return null;
850+
}
851+
852+
JsonObject payload = pauseStateToJson(snapshot);
853+
String message = pauseReasonMessage(snapshot);
854+
return ProtocolCodec.errorResponse(id, "PAUSED", message, payload);
855+
}
856+
857+
private static String pauseReasonMessage(PauseStateSnapshot snapshot) {
858+
if (snapshot.operatorPaused() && snapshot.gamePaused()) {
859+
return "Bridge request handling is paused (operator + game pause active)";
860+
}
861+
if (snapshot.operatorPaused()) {
862+
return "Bridge request handling is paused by #pyritone pause";
863+
}
864+
if (snapshot.gamePaused()) {
865+
return "Bridge request handling is paused because Minecraft is paused";
866+
}
867+
return "Bridge request handling is paused";
868+
}
869+
870+
private boolean setOperatorPauseActive(boolean active) {
871+
PauseStateSnapshot snapshot = null;
872+
synchronized (pauseStateLock) {
873+
if (operatorPauseActive == active) {
874+
return false;
875+
}
876+
operatorPauseActive = active;
877+
snapshot = updatePauseStateLocked();
878+
}
879+
publishPauseStateEvent(snapshot);
880+
return true;
881+
}
882+
883+
private boolean setGamePauseActive(boolean active) {
884+
PauseStateSnapshot snapshot = null;
885+
synchronized (pauseStateLock) {
886+
if (gamePauseActive == active) {
887+
return false;
888+
}
889+
gamePauseActive = active;
890+
snapshot = updatePauseStateLocked();
891+
}
892+
publishPauseStateEvent(snapshot);
893+
return true;
894+
}
895+
896+
private void clearOperatorPauseStateFromControl() {
897+
PauseStateSnapshot snapshot = null;
898+
synchronized (pauseStateLock) {
899+
if (!operatorPauseActive) {
900+
return;
901+
}
902+
operatorPauseActive = false;
903+
snapshot = updatePauseStateLocked();
904+
}
905+
publishPauseStateEvent(snapshot);
906+
}
907+
908+
private void clearPauseStateForShutdown() {
909+
PauseStateSnapshot snapshot = null;
910+
synchronized (pauseStateLock) {
911+
if (!operatorPauseActive && !gamePauseActive && !effectivePauseActive) {
912+
return;
913+
}
914+
operatorPauseActive = false;
915+
gamePauseActive = false;
916+
snapshot = updatePauseStateLocked();
917+
}
918+
publishPauseStateEvent(snapshot);
919+
}
920+
921+
private PauseStateSnapshot currentPauseStateSnapshot() {
922+
synchronized (pauseStateLock) {
923+
return new PauseStateSnapshot(
924+
effectivePauseActive,
925+
operatorPauseActive,
926+
gamePauseActive,
927+
pauseReason(operatorPauseActive, gamePauseActive),
928+
pauseStateSeq
929+
);
930+
}
931+
}
932+
933+
private PauseStateSnapshot updatePauseStateLocked() {
934+
effectivePauseActive = operatorPauseActive || gamePauseActive;
935+
pauseStateSeq += 1L;
936+
return new PauseStateSnapshot(
937+
effectivePauseActive,
938+
operatorPauseActive,
939+
gamePauseActive,
940+
pauseReason(operatorPauseActive, gamePauseActive),
941+
pauseStateSeq
942+
);
943+
}
944+
945+
private void publishPauseStateEvent(PauseStateSnapshot snapshot) {
946+
if (snapshot == null) {
947+
return;
948+
}
949+
publishEvent("bridge.pause_state", pauseStateToJson(snapshot));
950+
}
951+
952+
private void emitPauseStateEventToSession(WebSocketBridgeServer.ClientSession session, PauseStateSnapshot snapshot) {
953+
WebSocketBridgeServer currentServer = this.server;
954+
if (currentServer == null || !currentServer.isRunning() || session == null || snapshot == null) {
955+
return;
956+
}
957+
JsonObject envelope = ProtocolCodec.eventEnvelope("bridge.pause_state", pauseStateToJson(snapshot));
958+
currentServer.publishEvent(session, envelope);
959+
}
960+
961+
private static JsonObject pauseStateToJson(PauseStateSnapshot snapshot) {
962+
JsonObject payload = new JsonObject();
963+
payload.addProperty("paused", snapshot.paused());
964+
payload.addProperty("operator_paused", snapshot.operatorPaused());
965+
payload.addProperty("game_paused", snapshot.gamePaused());
966+
payload.addProperty("reason", snapshot.reason());
967+
payload.addProperty("seq", snapshot.seq());
968+
return payload;
969+
}
970+
971+
private static String pauseReason(boolean operatorPaused, boolean gamePaused) {
972+
if (operatorPaused && gamePaused) {
973+
return "operator_and_game_pause";
974+
}
975+
if (operatorPaused) {
976+
return "operator_pause";
977+
}
978+
if (gamePaused) {
979+
return "game_pause";
980+
}
981+
return "resumed";
982+
}
983+
984+
private boolean hasAnotherAuthenticatedSession(WebSocketBridgeServer.ClientSession session) {
985+
WebSocketBridgeServer currentServer = this.server;
986+
if (currentServer == null || !currentServer.isRunning()) {
987+
return false;
988+
}
989+
990+
for (WebSocketBridgeServer.ClientSession candidate : currentServer.sessionSnapshot()) {
991+
if (candidate == session) {
992+
continue;
993+
}
994+
if (candidate.isAuthenticated()) {
995+
return true;
996+
}
997+
}
998+
return false;
999+
}
1000+
7351001
private static String asString(JsonObject source, String key) {
7361002
if (source == null || !source.has(key) || !source.get(key).isJsonPrimitive()) {
7371003
return null;
@@ -798,6 +1064,15 @@ private static MutableText pyritonePrefix() {
7981064
prefix.append(Text.literal("]").formatted(Formatting.DARK_GREEN));
7991065
return prefix;
8001066
}
1067+
1068+
private record PauseStateSnapshot(
1069+
boolean paused,
1070+
boolean operatorPaused,
1071+
boolean gamePaused,
1072+
String reason,
1073+
long seq
1074+
) {
1075+
}
8011076
}
8021077

8031078

0 commit comments

Comments
 (0)