5353
5454public 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