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(sync): add automatic Minecraft stat syncing

Add a periodic server-side stat sync service that snapshots linked, authenticated players and records their Minecraft statistics to AT Protocol.

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

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

+559
+5
.gitignore
··· 32 32 33 33 run/ 34 34 35 + # nix 36 + 37 + result/ 38 + .direnv/ 39 + 35 40 # java 36 41 37 42 hs_err_*.log
+37
flake.nix
··· 1 + { 2 + description = "Social Sync development shell"; 3 + 4 + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; 5 + 6 + outputs = { nixpkgs, ... }: 7 + let 8 + systems = [ 9 + "x86_64-linux" 10 + "aarch64-linux" 11 + "x86_64-darwin" 12 + "aarch64-darwin" 13 + ]; 14 + 15 + forAllSystems = f: 16 + nixpkgs.lib.genAttrs systems (system: 17 + f (import nixpkgs { 18 + inherit system; 19 + })); 20 + in 21 + { 22 + devShells = forAllSystems (pkgs: { 23 + default = pkgs.mkShell { 24 + packages = with pkgs; [ 25 + jdk21 26 + ]; 27 + 28 + shellHook = '' 29 + export JAVA_HOME="${pkgs.jdk21}" 30 + echo "Social Sync dev shell ready (OpenJDK 21 + Gradle wrapper)" 31 + ''; 32 + }; 33 + }); 34 + 35 + formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); 36 + }; 37 + }
+34
src/main/kotlin/com/jollywhoppers/Atprotoconnect.kt
··· 4 4 import com.jollywhoppers.atproto.server.AtProtoCommands 5 5 import com.jollywhoppers.atproto.server.AtProtoSessionManager 6 6 import com.jollywhoppers.atproto.server.PlayerIdentityStore 7 + import com.jollywhoppers.atproto.server.PlayerStatSyncService 8 + import com.jollywhoppers.atproto.server.RecordManager 7 9 import com.jollywhoppers.security.SecurityAuditor 8 10 import net.fabricmc.api.ModInitializer 9 11 import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback 10 12 import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents 13 + import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents 11 14 import net.fabricmc.loader.api.FabricLoader 12 15 import org.slf4j.LoggerFactory 13 16 import java.util.concurrent.Executors ··· 25 28 private set 26 29 27 30 lateinit var sessionManager: AtProtoSessionManager 31 + private set 32 + 33 + lateinit var recordManager: RecordManager 34 + private set 35 + 36 + lateinit var statSyncService: PlayerStatSyncService 28 37 private set 29 38 30 39 lateinit var commands: AtProtoCommands ··· 63 72 sessionManager = AtProtoSessionManager(sessionStorePath, atProtoClient) 64 73 logger.info("Session manager initialized with encryption at: $sessionStorePath") 65 74 75 + // Initialize record manager 76 + recordManager = RecordManager(sessionManager) 77 + logger.info("Record manager initialized") 78 + 79 + // Initialize automatic Minecraft stat syncing 80 + val statSyncStatePath = configDir.resolve("minecraft-stat-sync-state.json") 81 + statSyncService = PlayerStatSyncService( 82 + recordManager = recordManager, 83 + sessionManager = sessionManager, 84 + identityStore = identityStore, 85 + storageFile = statSyncStatePath 86 + ) 87 + logger.info("Minecraft stat sync service initialized at: $statSyncStatePath") 88 + 66 89 // Initialize command handler (with rate limiting and audit logging) 67 90 commands = AtProtoCommands(atProtoClient, identityStore, sessionManager) 68 91 ··· 74 97 75 98 // Register network packet handlers 76 99 com.jollywhoppers.network.ServerNetworkHandler.register() 100 + 101 + // Register periodic Minecraft stat sync checks 102 + ServerTickEvents.END_SERVER_TICK.register { server -> 103 + statSyncService.onServerTick(server) 104 + } 105 + logger.info("Minecraft stat sync tick handler registered") 77 106 78 107 // Schedule periodic cleanup tasks 79 108 setupCleanupTasks() ··· 91 120 logger.info(" ✓ Rate limiting (3 attempts / 15 min)") 92 121 logger.info(" ✓ Security audit logging") 93 122 logger.info(" ✓ Enhanced SSRF protection") 123 + logger.info(" ✓ Automatic Minecraft stat syncing") 94 124 logger.info("Players can use /atproto help to see available commands") 95 125 logger.info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") 96 126 } catch (e: Exception) { ··· 123 153 logger.info("Server stopping, shutting down atproto-connect components") 124 154 125 155 try { 156 + if (::statSyncService.isInitialized) { 157 + statSyncService.shutdown() 158 + } 159 + 126 160 // Shutdown scheduler 127 161 scheduler.shutdown() 128 162 if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+331
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerStatSyncService.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 kotlinx.serialization.encodeToString 11 + import kotlinx.serialization.json.Json 12 + import net.minecraft.server.MinecraftServer 13 + import net.minecraft.server.level.ServerPlayer 14 + import net.minecraft.stats.Stat 15 + import org.slf4j.LoggerFactory 16 + import java.nio.file.Path 17 + import java.security.MessageDigest 18 + import java.time.Instant 19 + import java.util.UUID 20 + import java.util.concurrent.ConcurrentHashMap 21 + 22 + /** 23 + * Periodically snapshots online players' stats and syncs them to AT Protocol. 24 + */ 25 + class PlayerStatSyncService( 26 + private val recordManager: RecordManager, 27 + private val sessionManager: AtProtoSessionManager, 28 + private val identityStore: PlayerIdentityStore, 29 + storageFile: Path, 30 + private val syncIntervalTicks: Long = 20L * 60L * 5L 31 + ) { 32 + private val logger = LoggerFactory.getLogger("atproto-connect") 33 + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 34 + private val syncStateStore = PlayerStatSyncStore(storageFile) 35 + private val activeSyncs = ConcurrentHashMap.newKeySet<UUID>() 36 + 37 + private val json = Json { 38 + prettyPrint = false 39 + ignoreUnknownKeys = true 40 + encodeDefaults = true 41 + } 42 + 43 + @Volatile 44 + private var nextEvaluationTick: Long = 0L 45 + 46 + fun onServerTick(server: MinecraftServer) { 47 + if (!server.isRunning || server.isStopped) { 48 + return 49 + } 50 + 51 + val currentTick = server.tickCount.toLong() 52 + if (currentTick < nextEvaluationTick) { 53 + return 54 + } 55 + 56 + nextEvaluationTick = currentTick + syncIntervalTicks 57 + 58 + val snapshots = server.playerList.players 59 + .mapNotNull { player -> buildSnapshot(server, player) } 60 + 61 + if (snapshots.isEmpty()) { 62 + return 63 + } 64 + 65 + snapshots.forEach { snapshot -> 66 + queueSync(snapshot) 67 + } 68 + } 69 + 70 + fun shutdown() { 71 + coroutineScope.cancel() 72 + } 73 + 74 + private fun queueSync(snapshot: StatsSnapshot) { 75 + if (!activeSyncs.add(snapshot.player.uuid)) { 76 + return 77 + } 78 + 79 + coroutineScope.launch { 80 + try { 81 + if (!syncStateStore.shouldSync(snapshot.player.uuid, snapshot.fingerprint)) { 82 + logger.debug( 83 + "Skipping stat sync for ${snapshot.player.username} (${snapshot.player.uuid}); snapshot unchanged" 84 + ) 85 + return@launch 86 + } 87 + 88 + syncStateStore.recordAttempt(snapshot.player.uuid) 89 + 90 + recordManager.createTypedRecord( 91 + playerUuid = snapshot.player.uuid, 92 + collection = COLLECTION_ID, 93 + record = snapshot.record 94 + ).getOrThrow() 95 + 96 + syncStateStore.recordSuccess(snapshot.player.uuid, snapshot.fingerprint) 97 + logger.info( 98 + "Synced Minecraft stats for ${snapshot.player.username} (${snapshot.player.uuid})" 99 + ) 100 + } catch (e: Exception) { 101 + val errorMessage = e.message ?: e.javaClass.simpleName 102 + syncStateStore.recordFailure(snapshot.player.uuid, errorMessage) 103 + logger.error( 104 + "Failed to sync Minecraft stats for ${snapshot.player.username} (${snapshot.player.uuid})", 105 + e 106 + ) 107 + } finally { 108 + activeSyncs.remove(snapshot.player.uuid) 109 + } 110 + } 111 + } 112 + 113 + private fun buildSnapshot(server: MinecraftServer, player: ServerPlayer): StatsSnapshot? { 114 + if (!identityStore.isLinked(player.uuid) || !sessionManager.hasSession(player.uuid)) { 115 + return null 116 + } 117 + 118 + val statistics = extractStatistics(player) 119 + if (statistics.isEmpty()) { 120 + logger.debug("No statistics found for ${player.name.string} (${player.uuid}); syncing baseline snapshot") 121 + } 122 + 123 + val orderedStatistics = statistics 124 + .sortedWith( 125 + compareBy<StatisticEntry> { it.category } 126 + .thenBy { it.key } 127 + .thenBy { it.value } 128 + ) 129 + .take(MAX_STATISTICS_PER_RECORD) 130 + 131 + val playtimeMinutes = extractPlaytimeMinutes(statistics) 132 + val fingerprint = computeFingerprint( 133 + StatsFingerprint( 134 + player = PlayerReference( 135 + uuid = player.uuid.toString(), 136 + username = player.name.string 137 + ), 138 + server = buildServerReference(server), 139 + statistics = orderedStatistics, 140 + playtimeMinutes = playtimeMinutes, 141 + level = player.experienceLevel, 142 + gamemode = mapGameMode(player), 143 + dimension = player.level().dimension().location().toString() 144 + ) 145 + ) 146 + 147 + val record = MinecraftPlayerStatsRecord( 148 + player = PlayerReference( 149 + uuid = player.uuid.toString(), 150 + username = player.name.string 151 + ), 152 + server = buildServerReference(server), 153 + statistics = orderedStatistics, 154 + playtimeMinutes = playtimeMinutes, 155 + level = player.experienceLevel, 156 + gamemode = mapGameMode(player), 157 + dimension = player.level().dimension().location().toString(), 158 + syncedAt = Instant.now().toString() 159 + ) 160 + 161 + return StatsSnapshot( 162 + player = SnapshotPlayer( 163 + uuid = player.uuid, 164 + username = player.name.string 165 + ), 166 + record = record, 167 + fingerprint = fingerprint 168 + ) 169 + } 170 + 171 + private fun extractStatistics(player: ServerPlayer): List<StatisticEntry> { 172 + val statsCounter = player.getStats() 173 + val statsField = runCatching { 174 + statsCounter.javaClass.superclass.getDeclaredField("stats").apply { 175 + isAccessible = true 176 + } 177 + }.getOrElse { error -> 178 + logger.warn( 179 + "Unable to access stats map for ${player.name.string} (${player.uuid}): ${error.message}", 180 + error 181 + ) 182 + return emptyList() 183 + } 184 + 185 + val statsMap = runCatching { 186 + @Suppress("UNCHECKED_CAST") 187 + statsField.get(statsCounter) as? Map<*, *> 188 + }.getOrElse { error -> 189 + logger.warn( 190 + "Unable to read stats map for ${player.name.string} (${player.uuid}): ${error.message}", 191 + error 192 + ) 193 + return emptyList() 194 + } ?: return emptyList() 195 + 196 + return statsMap.entries.mapNotNull { (rawStat, rawValue) -> 197 + val stat = rawStat as? Stat<*> ?: return@mapNotNull null 198 + val value = (rawValue as? Number)?.toInt() ?: return@mapNotNull null 199 + val category = normalizeStatCategory(stat.getType().getDisplayName().string) 200 + val key = "${category}/${stat.getValue()}" 201 + 202 + StatisticEntry( 203 + key = key, 204 + value = value, 205 + category = category 206 + ) 207 + } 208 + } 209 + 210 + private fun normalizeStatCategory(rawCategory: String): String { 211 + return rawCategory 212 + .lowercase() 213 + .replace(Regex("[^a-z0-9]+"), "_") 214 + .trim('_') 215 + .ifBlank { "custom" } 216 + } 217 + 218 + private fun extractPlaytimeMinutes(statistics: List<StatisticEntry>): Int { 219 + val playTimeTicks = statistics.firstOrNull { entry -> 220 + entry.key.contains("play_time", ignoreCase = true) 221 + }?.value ?: 0 222 + 223 + val minutes = playTimeTicks / 20 / 60 224 + return minutes.coerceAtLeast(0) 225 + } 226 + 227 + private fun buildServerReference(server: MinecraftServer): ServerReference { 228 + val serverId = buildServerId(server) 229 + val serverName = server.getMotd().ifBlank { "Minecraft Server" } 230 + val serverAddress = server.getLocalIp().takeIf { it.isNotBlank() }?.let { ip -> 231 + val port = server.getPort() 232 + if (port > 0) { 233 + "$ip:$port" 234 + } else { 235 + ip 236 + } 237 + } 238 + 239 + return ServerReference( 240 + serverId = serverId, 241 + serverName = serverName, 242 + serverAddress = serverAddress 243 + ) 244 + } 245 + 246 + private fun buildServerId(server: MinecraftServer): String { 247 + val serverPath = server.getServerDirectory() 248 + .toAbsolutePath() 249 + .normalize() 250 + .toString() 251 + 252 + val payload = "socialsync:$serverPath" 253 + val digest = MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) 254 + return digest.joinToString("") { byte -> "%02x".format(byte) } 255 + } 256 + 257 + private fun mapGameMode(player: ServerPlayer): String { 258 + return when (player.gameMode.getGameModeForPlayer().name.lowercase()) { 259 + "creative" -> "creative" 260 + "adventure" -> "adventure" 261 + "spectator" -> "spectator" 262 + else -> "survival" 263 + } 264 + } 265 + 266 + private fun computeFingerprint(fingerprint: StatsFingerprint): String { 267 + val payload = json.encodeToString(StatsFingerprint.serializer(), fingerprint) 268 + val digest = MessageDigest.getInstance("SHA-256").digest(payload.toByteArray(Charsets.UTF_8)) 269 + return digest.joinToString("") { byte -> "%02x".format(byte) } 270 + } 271 + 272 + data class SnapshotPlayer( 273 + val uuid: UUID, 274 + val username: String 275 + ) 276 + 277 + data class StatsSnapshot( 278 + val player: SnapshotPlayer, 279 + val record: MinecraftPlayerStatsRecord, 280 + val fingerprint: String 281 + ) 282 + 283 + @Serializable 284 + data class StatsFingerprint( 285 + val player: PlayerReference, 286 + val server: ServerReference, 287 + val statistics: List<StatisticEntry>, 288 + val playtimeMinutes: Int, 289 + val level: Int, 290 + val gamemode: String, 291 + val dimension: String 292 + ) 293 + 294 + @Serializable 295 + data class PlayerReference( 296 + val uuid: String, 297 + val username: String 298 + ) 299 + 300 + @Serializable 301 + data class ServerReference( 302 + val serverId: String, 303 + val serverName: String, 304 + val serverAddress: String? = null 305 + ) 306 + 307 + @Serializable 308 + data class StatisticEntry( 309 + val key: String, 310 + val value: Int, 311 + val category: String 312 + ) 313 + 314 + @Serializable 315 + data class MinecraftPlayerStatsRecord( 316 + @SerialName("\$type") val type: String = COLLECTION_ID, 317 + val player: PlayerReference, 318 + val server: ServerReference, 319 + val statistics: List<StatisticEntry>, 320 + val playtimeMinutes: Int, 321 + val level: Int, 322 + val gamemode: String, 323 + val dimension: String, 324 + val syncedAt: String 325 + ) 326 + 327 + companion object { 328 + private const val COLLECTION_ID = "com.jollywhoppers.minecraft.player.stats" 329 + private const val MAX_STATISTICS_PER_RECORD = 1000 330 + } 331 + }
+152
src/main/kotlin/com/jollywhoppers/atproto/server/PlayerStatSyncStore.kt
··· 1 + package com.jollywhoppers.atproto.server 2 + 3 + import com.jollywhoppers.security.SecurityUtils 4 + import kotlinx.serialization.Serializable 5 + import kotlinx.serialization.json.Json 6 + import org.slf4j.LoggerFactory 7 + import java.nio.file.Files 8 + import java.nio.file.Path 9 + import java.nio.file.StandardOpenOption 10 + import java.util.UUID 11 + import java.util.concurrent.ConcurrentHashMap 12 + import java.util.concurrent.locks.ReentrantReadWriteLock 13 + import kotlin.concurrent.read 14 + import kotlin.concurrent.write 15 + 16 + /** 17 + * Persistent sync state for Minecraft stat snapshots. 18 + * Tracks the last successful snapshot hash so the sync service can skip duplicates. 19 + */ 20 + class PlayerStatSyncStore(private val storageFile: Path) { 21 + private val logger = LoggerFactory.getLogger("atproto-connect") 22 + private val states = ConcurrentHashMap<UUID, SyncState>() 23 + private val fileLock = ReentrantReadWriteLock() 24 + 25 + private val json = Json { 26 + prettyPrint = true 27 + ignoreUnknownKeys = true 28 + } 29 + 30 + @Serializable 31 + data class SyncState( 32 + val uuid: String, 33 + val lastSyncedHash: String? = null, 34 + val lastSyncedAt: Long? = null, 35 + val lastAttemptAt: Long? = null, 36 + val lastError: String? = null 37 + ) 38 + 39 + @Serializable 40 + private data class SyncStorage( 41 + val version: Int = 1, 42 + val states: List<SyncState> 43 + ) 44 + 45 + init { 46 + val configDir = storageFile.parent 47 + if (!SecurityUtils.validatePathInDirectory(storageFile, configDir)) { 48 + throw SecurityException("Storage file path is outside expected directory") 49 + } 50 + 51 + load() 52 + logger.info("Player stat sync store initialized with ${states.size} entries") 53 + } 54 + 55 + fun getState(uuid: UUID): SyncState? = states[uuid] 56 + 57 + fun shouldSync(uuid: UUID, snapshotHash: String): Boolean { 58 + return states[uuid]?.lastSyncedHash != snapshotHash 59 + } 60 + 61 + fun recordAttempt(uuid: UUID) { 62 + updateState(uuid) { current -> 63 + current.copy( 64 + lastAttemptAt = System.currentTimeMillis(), 65 + lastError = null 66 + ) 67 + } 68 + } 69 + 70 + fun recordSuccess(uuid: UUID, snapshotHash: String) { 71 + updateState(uuid) { current -> 72 + current.copy( 73 + lastSyncedHash = snapshotHash, 74 + lastSyncedAt = System.currentTimeMillis(), 75 + lastAttemptAt = System.currentTimeMillis(), 76 + lastError = null 77 + ) 78 + } 79 + } 80 + 81 + fun recordFailure(uuid: UUID, errorMessage: String) { 82 + updateState(uuid) { current -> 83 + current.copy( 84 + lastAttemptAt = System.currentTimeMillis(), 85 + lastError = errorMessage.take(500) 86 + ) 87 + } 88 + } 89 + 90 + private fun updateState(uuid: UUID, transform: (SyncState) -> SyncState) = fileLock.write { 91 + val updated = transform( 92 + states[uuid] ?: SyncState(uuid = uuid.toString()) 93 + ) 94 + states[uuid] = updated.copy(uuid = uuid.toString()) 95 + save() 96 + } 97 + 98 + private fun load() { 99 + fileLock.read { 100 + try { 101 + if (!Files.exists(storageFile)) { 102 + logger.info("No existing player stat sync store found, starting fresh") 103 + return@read 104 + } 105 + 106 + val content = Files.readString(storageFile) 107 + val storage = json.decodeFromString(SyncStorage.serializer(), content) 108 + 109 + storage.states.forEach { state -> 110 + states[UUID.fromString(state.uuid)] = state 111 + } 112 + 113 + logger.info("Loaded ${states.size} player stat sync entries from disk") 114 + } catch (e: Exception) { 115 + logger.error("Failed to load player stat sync state", e) 116 + } 117 + } 118 + } 119 + 120 + private fun save() { 121 + try { 122 + Files.createDirectories(storageFile.parent) 123 + 124 + val storage = SyncStorage( 125 + version = 1, 126 + states = states.values.sortedBy { it.uuid } 127 + ) 128 + val content = json.encodeToString(SyncStorage.serializer(), storage) 129 + 130 + val tempFile = storageFile.parent.resolve("${storageFile.fileName}.tmp") 131 + Files.writeString( 132 + tempFile, 133 + content, 134 + StandardOpenOption.CREATE, 135 + StandardOpenOption.TRUNCATE_EXISTING 136 + ) 137 + 138 + SecurityUtils.setRestrictedPermissions(tempFile) 139 + 140 + Files.move( 141 + tempFile, 142 + storageFile, 143 + java.nio.file.StandardCopyOption.REPLACE_EXISTING, 144 + java.nio.file.StandardCopyOption.ATOMIC_MOVE 145 + ) 146 + 147 + logger.debug("Saved ${states.size} player stat sync entries to disk") 148 + } catch (e: Exception) { 149 + logger.error("Failed to save player stat sync state", e) 150 + } 151 + } 152 + }