Skip to content

Commit 392edd4

Browse files
committed
Add Discord-style event client and stabilize Minecraft lifecycle events
1 parent 872c2cb commit 392edd4

File tree

19 files changed

+1482
-47
lines changed

19 files changed

+1482
-47
lines changed

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

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package com.pyritone.bridge;
22

3-
import com.google.gson.JsonElement;
43
import com.google.gson.JsonArray;
4+
import com.google.gson.JsonElement;
5+
import com.google.gson.JsonNull;
56
import com.google.gson.JsonObject;
67
import com.pyritone.bridge.command.PyritoneCommand;
78
import com.pyritone.bridge.config.BridgeConfig;
@@ -11,6 +12,7 @@
1112
import com.pyritone.bridge.net.WebSocketBridgeServer;
1213
import com.pyritone.bridge.runtime.BaritoneGateway;
1314
import com.pyritone.bridge.runtime.EntityTypeSelector;
15+
import com.pyritone.bridge.runtime.PlayerLifecycleTracker;
1416
import com.pyritone.bridge.runtime.StatusSubscriptionRegistry;
1517
import com.pyritone.bridge.runtime.TaskRegistry;
1618
import com.pyritone.bridge.runtime.TaskLifecycleResolver;
@@ -19,12 +21,16 @@
1921
import com.pyritone.bridge.runtime.TypedApiException;
2022
import com.pyritone.bridge.runtime.TypedApiService;
2123
import com.pyritone.bridge.runtime.WatchPatternRegistry;
24+
import com.mojang.authlib.GameProfile;
2225
import net.fabricmc.api.ClientModInitializer;
2326
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
27+
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
2428
import net.fabricmc.fabric.api.client.message.v1.ClientSendMessageEvents;
2529
import net.fabricmc.loader.api.FabricLoader;
2630
import net.minecraft.client.MinecraftClient;
31+
import net.minecraft.client.network.PlayerListEntry;
2732
import net.minecraft.entity.Entity;
33+
import net.minecraft.entity.player.PlayerEntity;
2834
import net.minecraft.registry.Registries;
2935
import net.minecraft.text.MutableText;
3036
import net.minecraft.text.Text;
@@ -41,8 +47,10 @@
4147
import java.time.Instant;
4248
import java.util.ArrayList;
4349
import java.util.Comparator;
50+
import java.util.HashMap;
4451
import java.util.List;
4552
import java.util.Locale;
53+
import java.util.Map;
4654
import java.util.Optional;
4755
import java.util.Set;
4856
import java.util.concurrent.CompletionException;
@@ -73,6 +81,10 @@ public final class PyritoneBridgeClientMod implements ClientModInitializer {
7381
private final WatchPatternRegistry watchPatternRegistry = new WatchPatternRegistry();
7482
private final StatusSubscriptionRegistry statusSubscriptionRegistry = new StatusSubscriptionRegistry();
7583
private final TypedApiService typedApiService = new TypedApiService(PyritoneBridgeClientMod.class.getClassLoader());
84+
private final PlayerLifecycleTracker playerLifecycleTracker = new PlayerLifecycleTracker();
85+
private boolean playerLifecycleInWorld;
86+
private boolean playerLifecycleSelfJoinEmitted;
87+
private PlayerLifecycleTracker.PlayerSnapshot lastKnownSelfPlayer;
7688

7789
private final Object pauseStateLock = new Object();
7890
private volatile boolean operatorPauseActive;
@@ -107,9 +119,14 @@ public void onInitializeClient() {
107119
);
108120
baritoneGateway.tickApplyPyritoneChatBranding();
109121
tickPauseState(client);
122+
tickPlayerLifecycleEvents(client);
110123
tickTaskLifecycle();
111124
tickStatusStreams();
112125
});
126+
ClientReceiveMessageEvents.CHAT.register(
127+
(message, signedMessage, sender, params, receptionTimestamp) -> onIncomingChatMessage(message, sender)
128+
);
129+
ClientReceiveMessageEvents.GAME.register(this::onIncomingSystemMessage);
113130
ClientSendMessageEvents.ALLOW_CHAT.register(this::handleOutgoingChat);
114131
ClientSendMessageEvents.ALLOW_COMMAND.register(this::handleOutgoingCommand);
115132

@@ -211,6 +228,10 @@ private void startBridgeServer() {
211228

212229
private void shutdownBridgeServer() {
213230
clearPauseStateForShutdown();
231+
playerLifecycleTracker.reset();
232+
playerLifecycleInWorld = false;
233+
playerLifecycleSelfJoinEmitted = false;
234+
lastKnownSelfPlayer = null;
214235
statusSubscriptionRegistry.clear();
215236
typedApiService.clear();
216237
if (this.server != null) {
@@ -414,6 +435,8 @@ private JsonObject buildStatusPayload(WebSocketBridgeServer.ClientSession sessio
414435
result.addProperty("in_world", baritoneGateway.isInWorld());
415436
result.add("active_task", taskRegistry.activeAsJson());
416437
result.add("watch_patterns", watchPatternRegistry.toJsonArray());
438+
JsonObject player = currentPlayerPayload(MinecraftClient.getInstance());
439+
result.add("player", player != null ? player : JsonNull.INSTANCE);
417440
return result;
418441
}
419442

@@ -648,6 +671,86 @@ private void tickPauseState(MinecraftClient client) {
648671
setGamePauseActive(paused);
649672
}
650673

674+
private void tickPlayerLifecycleEvents(MinecraftClient client) {
675+
if (client == null || client.world == null || client.getNetworkHandler() == null) {
676+
if (playerLifecycleInWorld && lastKnownSelfPlayer != null) {
677+
emitPlayerLifecycleEvent("minecraft.player_leave", lastKnownSelfPlayer);
678+
}
679+
playerLifecycleTracker.reset();
680+
playerLifecycleInWorld = false;
681+
playerLifecycleSelfJoinEmitted = false;
682+
lastKnownSelfPlayer = null;
683+
return;
684+
}
685+
686+
if (!playerLifecycleInWorld) {
687+
playerLifecycleInWorld = true;
688+
playerLifecycleSelfJoinEmitted = false;
689+
}
690+
691+
Map<String, Boolean> aliveByUuid = new HashMap<>();
692+
String localPlayerUuid = null;
693+
if (client.player != null) {
694+
localPlayerUuid = client.player.getUuidAsString();
695+
}
696+
697+
for (PlayerEntity player : client.world.getPlayers()) {
698+
if (player == null) {
699+
continue;
700+
}
701+
String uuid = player.getUuidAsString();
702+
if (uuid == null || uuid.isBlank()) {
703+
continue;
704+
}
705+
aliveByUuid.put(uuid, player.isAlive());
706+
}
707+
708+
List<PlayerLifecycleTracker.PlayerSnapshot> players = new ArrayList<>();
709+
PlayerLifecycleTracker.PlayerSnapshot selfSnapshot = null;
710+
for (PlayerListEntry entry : client.getNetworkHandler().getPlayerList()) {
711+
if (entry == null || entry.getProfile() == null || entry.getProfile().getId() == null) {
712+
continue;
713+
}
714+
String uuid = entry.getProfile().getId().toString();
715+
String name = entry.getProfile().getName();
716+
if (name == null || name.isBlank()) {
717+
name = "unknown";
718+
}
719+
boolean self = localPlayerUuid != null && localPlayerUuid.equals(uuid);
720+
Boolean aliveValue = aliveByUuid.get(uuid);
721+
boolean aliveKnown = aliveValue != null;
722+
boolean alive = aliveKnown && aliveValue;
723+
PlayerLifecycleTracker.PlayerSnapshot snapshot =
724+
new PlayerLifecycleTracker.PlayerSnapshot(uuid, name, alive, self, aliveKnown);
725+
players.add(snapshot);
726+
if (self) {
727+
selfSnapshot = snapshot;
728+
}
729+
}
730+
731+
List<PlayerLifecycleTracker.PlayerEvent> events = playerLifecycleTracker.update(players);
732+
for (PlayerLifecycleTracker.PlayerEvent event : events) {
733+
String eventName = switch (event.type()) {
734+
case JOIN -> "minecraft.player_join";
735+
case LEAVE -> "minecraft.player_leave";
736+
case DEATH -> "minecraft.player_death";
737+
case RESPAWN -> "minecraft.player_respawn";
738+
};
739+
emitPlayerLifecycleEvent(eventName, event.player());
740+
if (event.type() == PlayerLifecycleTracker.PlayerEventType.JOIN
741+
&& event.player() != null
742+
&& event.player().self()) {
743+
playerLifecycleSelfJoinEmitted = true;
744+
}
745+
}
746+
747+
if (!playerLifecycleSelfJoinEmitted && selfSnapshot != null) {
748+
emitPlayerLifecycleEvent("minecraft.player_join", selfSnapshot);
749+
playerLifecycleSelfJoinEmitted = true;
750+
}
751+
lastKnownSelfPlayer = selfSnapshot;
752+
}
753+
651754
private void tickTaskLifecycle() {
652755
Optional<TaskSnapshot> active = taskRegistry.active();
653756
if (active.isEmpty()) {
@@ -733,6 +836,65 @@ private void publishStatusEvent(
733836
currentServer.publishEvent(session, ProtocolCodec.eventEnvelope("status.update", data));
734837
}
735838

839+
private void onIncomingChatMessage(Text message, GameProfile sender) {
840+
MinecraftClient client = MinecraftClient.getInstance();
841+
JsonObject authorPayload = null;
842+
if (sender != null) {
843+
String uuid = sender.getId() != null ? sender.getId().toString() : null;
844+
String name = sender.getName() != null && !sender.getName().isBlank() ? sender.getName() : "unknown";
845+
boolean self = false;
846+
if (client != null && client.player != null && uuid != null && !uuid.isBlank()) {
847+
self = uuid.equals(client.player.getUuidAsString());
848+
}
849+
authorPayload = playerPayload(uuid, name, self);
850+
}
851+
emitChatMessageEvent(message != null ? message.getString() : "", authorPayload);
852+
}
853+
854+
private void onIncomingSystemMessage(Text message, boolean overlay) {
855+
JsonObject data = new JsonObject();
856+
data.addProperty("message", message != null ? message.getString() : "");
857+
data.addProperty("overlay", overlay);
858+
publishEvent("minecraft.system_message", data);
859+
}
860+
861+
private void emitChatMessageEvent(String message, JsonObject author) {
862+
JsonObject data = new JsonObject();
863+
data.addProperty("message", message == null ? "" : message);
864+
data.add("author", author != null ? author : JsonNull.INSTANCE);
865+
publishEvent("minecraft.chat_message", data);
866+
}
867+
868+
private void emitPlayerLifecycleEvent(String eventName, PlayerLifecycleTracker.PlayerSnapshot player) {
869+
if (player == null) {
870+
return;
871+
}
872+
JsonObject data = new JsonObject();
873+
data.add("player", playerPayload(player.uuid(), player.name(), player.self()));
874+
publishEvent(eventName, data);
875+
}
876+
877+
private static JsonObject playerPayload(String uuid, String name, boolean self) {
878+
JsonObject payload = new JsonObject();
879+
if (uuid != null && !uuid.isBlank()) {
880+
payload.addProperty("uuid", uuid);
881+
} else {
882+
payload.add("uuid", JsonNull.INSTANCE);
883+
}
884+
payload.addProperty("name", name == null || name.isBlank() ? "unknown" : name);
885+
payload.addProperty("self", self);
886+
return payload;
887+
}
888+
889+
private static JsonObject currentPlayerPayload(MinecraftClient client) {
890+
if (client == null || client.player == null) {
891+
return null;
892+
}
893+
String uuid = client.player.getUuidAsString();
894+
String name = client.player.getName() != null ? client.player.getName().getString() : "unknown";
895+
return playerPayload(uuid, name, true);
896+
}
897+
736898
private void handlePausedUpdate(TaskSnapshot current, TaskLifecycleResolver.PauseStatus pauseStatus) {
737899
String detail = pauseStatusDetail(pauseStatus);
738900
Optional<TaskSnapshot> updated = taskRegistry.updateActiveDetail(detail);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.pyritone.bridge.runtime;
2+
3+
import java.util.ArrayList;
4+
import java.util.HashMap;
5+
import java.util.List;
6+
import java.util.Map;
7+
8+
/**
9+
* Tracks client-visible players and emits lifecycle transitions.
10+
*
11+
* <p>First observed snapshot seeds internal state and emits nothing.
12+
*/
13+
public final class PlayerLifecycleTracker {
14+
private static final int DEFAULT_LEAVE_GRACE_TICKS = 3;
15+
16+
private final Map<String, PlayerSnapshot> knownByUuid = new HashMap<>();
17+
private final Map<String, Integer> missingTicksByUuid = new HashMap<>();
18+
private final int leaveGraceTicks;
19+
private boolean initialized;
20+
21+
public PlayerLifecycleTracker() {
22+
this(DEFAULT_LEAVE_GRACE_TICKS);
23+
}
24+
25+
public PlayerLifecycleTracker(int leaveGraceTicks) {
26+
this.leaveGraceTicks = Math.max(1, leaveGraceTicks);
27+
}
28+
29+
public synchronized List<PlayerEvent> update(List<PlayerSnapshot> currentPlayers) {
30+
List<PlayerEvent> events = new ArrayList<>();
31+
Map<String, PlayerSnapshot> currentByUuid = new HashMap<>();
32+
for (PlayerSnapshot snapshot : currentPlayers) {
33+
if (snapshot == null || snapshot.uuid() == null || snapshot.uuid().isBlank()) {
34+
continue;
35+
}
36+
currentByUuid.put(snapshot.uuid(), snapshot);
37+
}
38+
39+
if (!initialized) {
40+
knownByUuid.clear();
41+
missingTicksByUuid.clear();
42+
knownByUuid.putAll(currentByUuid);
43+
initialized = true;
44+
return events;
45+
}
46+
47+
for (Map.Entry<String, PlayerSnapshot> entry : currentByUuid.entrySet()) {
48+
PlayerSnapshot current = entry.getValue();
49+
PlayerSnapshot previous = knownByUuid.get(current.uuid());
50+
if (previous == null) {
51+
events.add(new PlayerEvent(PlayerEventType.JOIN, current));
52+
continue;
53+
}
54+
55+
missingTicksByUuid.remove(current.uuid());
56+
57+
PlayerSnapshot effectiveCurrent = current;
58+
if (!current.aliveKnown() && previous.aliveKnown()) {
59+
effectiveCurrent = new PlayerSnapshot(
60+
current.uuid(),
61+
current.name(),
62+
previous.alive(),
63+
current.self(),
64+
true
65+
);
66+
entry.setValue(effectiveCurrent);
67+
}
68+
69+
if (previous.aliveKnown() && effectiveCurrent.aliveKnown()) {
70+
if (previous.alive() && !effectiveCurrent.alive()) {
71+
events.add(new PlayerEvent(PlayerEventType.DEATH, effectiveCurrent));
72+
} else if (!previous.alive() && effectiveCurrent.alive()) {
73+
events.add(new PlayerEvent(PlayerEventType.RESPAWN, effectiveCurrent));
74+
}
75+
}
76+
}
77+
78+
for (PlayerSnapshot previous : knownByUuid.values()) {
79+
String uuid = previous.uuid();
80+
if (!currentByUuid.containsKey(uuid)) {
81+
int missingTicks = missingTicksByUuid.getOrDefault(uuid, 0) + 1;
82+
if (missingTicks >= leaveGraceTicks) {
83+
missingTicksByUuid.remove(uuid);
84+
events.add(new PlayerEvent(PlayerEventType.LEAVE, previous));
85+
} else {
86+
missingTicksByUuid.put(uuid, missingTicks);
87+
// Keep prior state during short disappearances (for example, death/respawn transitions).
88+
currentByUuid.put(uuid, previous);
89+
}
90+
}
91+
}
92+
93+
knownByUuid.clear();
94+
knownByUuid.putAll(currentByUuid);
95+
return events;
96+
}
97+
98+
public synchronized void reset() {
99+
knownByUuid.clear();
100+
missingTicksByUuid.clear();
101+
initialized = false;
102+
}
103+
104+
public enum PlayerEventType {
105+
JOIN,
106+
LEAVE,
107+
DEATH,
108+
RESPAWN
109+
}
110+
111+
public record PlayerSnapshot(
112+
String uuid,
113+
String name,
114+
boolean alive,
115+
boolean self,
116+
boolean aliveKnown
117+
) {
118+
public PlayerSnapshot(String uuid, String name, boolean alive, boolean self) {
119+
this(uuid, name, alive, self, true);
120+
}
121+
}
122+
123+
public record PlayerEvent(
124+
PlayerEventType type,
125+
PlayerSnapshot player
126+
) {
127+
}
128+
}

0 commit comments

Comments
 (0)