A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(server): implement server status snapshots

Periodically sync server status to AT Protocol with literal:self rkey.
Includes version, online players, MOTD, game mode, difficulty, PvP status.
Syncs every 5 minutes when at least one authenticated player is online
(their session is used for the write).

Note: Requires an authenticated player to write. A future "server account"
concept would allow server operators to authenticate independently.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+163
+163
src/main/kotlin/com/jollywhoppers/atproto/server/ServerStatusSyncService.kt
··· 1 + package com.jollywhoppers.atproto.server 2 + 3 + import kotlinx.coroutines.CoroutineScope 4 + import kotlinx.coroutines.Dispatchers 5 + import kotlinx.coroutines.SupervisorJob 6 + import kotlinx.coroutines.cancel 7 + import kotlinx.coroutines.launch 8 + import kotlinx.serialization.SerialName 9 + import kotlinx.serialization.Serializable 10 + import net.minecraft.server.MinecraftServer 11 + import net.minecraft.server.level.ServerPlayer 12 + import org.slf4j.LoggerFactory 13 + import java.security.MessageDigest 14 + import java.time.Instant 15 + import java.util.UUID 16 + 17 + /** 18 + * Periodically syncs server status to AT Protocol. 19 + * 20 + * Creates a `com.jollywhoppers.minecraft.server.status` record with the 21 + * `literal:self` rkey, meaning one status record per server. 22 + * 23 + * Requirements: 24 + * - At least one authenticated player must be online (we need their session 25 + * to write to AT Protocol) 26 + * - The first authenticated player's session is used for the write 27 + * 28 + * This is a limitation of the current design — a future "server account" 29 + * concept would allow the server operator to authenticate independently. 30 + */ 31 + class ServerStatusSyncService( 32 + private val recordManager: RecordManager, 33 + private val sessionManager: AtProtoSessionManager, 34 + private val identityStore: PlayerIdentityStore, 35 + ) { 36 + private val logger = LoggerFactory.getLogger("atproto-connect:server-status") 37 + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 38 + 39 + companion object { 40 + private const val COLLECTION_ID = "com.jollywhoppers.minecraft.server.status" 41 + private const val RKEY = "self" 42 + } 43 + 44 + /** 45 + * Called periodically to sync server status. 46 + * Only syncs if there's at least one authenticated player online. 47 + */ 48 + fun onSyncTick(server: MinecraftServer) { 49 + if (!server.isRunning || server.isStopped) return 50 + 51 + // Find an authenticated player to use their session 52 + val authenticatedPlayer = server.playerList.players.firstOrNull { player -> 53 + identityStore.isLinked(player.uuid) && sessionManager.hasSession(player.uuid) 54 + } 55 + 56 + if (authenticatedPlayer == null) { 57 + logger.debug("Skipping server status sync: no authenticated players online") 58 + return 59 + } 60 + 61 + coroutineScope.launch { 62 + try { 63 + val status = buildStatus(server) 64 + recordManager.putTypedRecord( 65 + playerUuid = authenticatedPlayer.uuid, 66 + collection = COLLECTION_ID, 67 + rkey = RKEY, 68 + record = status, 69 + ).getOrThrow() 70 + 71 + logger.info("Synced server status for ${status.server.serverName}") 72 + } catch (e: Exception) { 73 + logger.error("Failed to sync server status", e) 74 + } 75 + } 76 + } 77 + 78 + private fun buildStatus(server: MinecraftServer): MinecraftServerStatusRecord { 79 + val serverId = buildServerId(server) 80 + val serverName = server.getMotd().ifBlank { "Minecraft Server" } 81 + val serverAddress = server.getLocalIp().takeIf { it.isNotBlank() }?.let { ip -> 82 + val port = server.getPort() 83 + if (port > 0) "$ip:$port" else ip 84 + } 85 + 86 + val onlinePlayers = server.playerList.players.map { player -> 87 + PlayerReference( 88 + uuid = player.uuid.toString(), 89 + username = player.name.string, 90 + ) 91 + }.take(100) // Limit per lexicon 92 + 93 + val overworld = server.getLevel(net.minecraft.world.level.Level.OVERWORLD) 94 + 95 + return MinecraftServerStatusRecord( 96 + server = ServerReference( 97 + serverId = serverId, 98 + serverName = serverName, 99 + serverAddress = serverAddress, 100 + ), 101 + version = server.serverVersion, 102 + protocolVersion = null, // Not directly accessible 103 + maxPlayers = server.maxPlayers, 104 + onlinePlayers = server.playerList.players.size, 105 + playerSample = onlinePlayers.takeIf { it.isNotEmpty() }, 106 + motd = server.getMotd().takeIf { it.isNotBlank() }, 107 + gameMode = inferPrimaryGameMode(server), 108 + difficulty = overworld?.difficulty?.name?.lowercase() ?: "normal", 109 + hardcore = server.isHardcore, 110 + pvpEnabled = server.isPvpAllowed, 111 + updatedAt = Instant.now().toString(), 112 + ) 113 + } 114 + 115 + private fun inferPrimaryGameMode(server: MinecraftServer): String { 116 + // Default to survival; could be enhanced to check default game mode 117 + return server.getDefaultGameType().name.lowercase() 118 + } 119 + 120 + private fun buildServerId(server: MinecraftServer): String { 121 + val serverPath = server.serverDirectory 122 + .toAbsolutePath() 123 + .normalize() 124 + .toString() 125 + val payload = "socialsync:$serverPath" 126 + val digest = MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) 127 + return digest.joinToString("") { byte -> "%02x".format(byte) } 128 + } 129 + 130 + fun shutdown() { 131 + coroutineScope.cancel() 132 + } 133 + 134 + @Serializable 135 + data class PlayerReference( 136 + val uuid: String, 137 + val username: String, 138 + ) 139 + 140 + @Serializable 141 + data class ServerReference( 142 + val serverId: String, 143 + val serverName: String, 144 + val serverAddress: String? = null, 145 + ) 146 + 147 + @Serializable 148 + data class MinecraftServerStatusRecord( 149 + @SerialName("\$type") val type: String = COLLECTION_ID, 150 + val server: ServerReference, 151 + val version: String, 152 + val protocolVersion: Int? = null, 153 + val maxPlayers: Int? = null, 154 + val onlinePlayers: Int, 155 + val playerSample: List<PlayerReference>? = null, 156 + val motd: String? = null, 157 + val gameMode: String? = null, 158 + val difficulty: String = "normal", 159 + val hardcore: Boolean = false, 160 + val pvpEnabled: Boolean = true, 161 + val updatedAt: String, 162 + ) 163 + }