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(achievements): sync Minecraft advancements to AT Protocol

Add AchievementSyncService that creates achievement records when a
player earns a full advancement. Uses a mixin on PlayerAdvancements.award()
to detect completion. Respects the publicStats privacy setting and
deduplicates within a session. Advancement records include category,
challenge status, and human-readable names.

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

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

+227 -1
+57
src/main/java/com/jollywhoppers/mixin/PlayerAdvancementsMixin.java
··· 1 + package com.jollywhoppers.mixin; 2 + 3 + import com.jollywhoppers.atproto.server.AchievementSyncService; 4 + import net.minecraft.advancements.AdvancementHolder; 5 + import net.minecraft.advancements.AdvancementProgress; 6 + import net.minecraft.server.PlayerAdvancements; 7 + import net.minecraft.server.level.ServerPlayer; 8 + import org.slf4j.Logger; 9 + import org.slf4j.LoggerFactory; 10 + import org.spongepowered.asm.mixin.Mixin; 11 + import org.spongepowered.asm.mixin.Shadow; 12 + import org.spongepowered.asm.mixin.injection.At; 13 + import org.spongepowered.asm.mixin.injection.Inject; 14 + import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 15 + 16 + /** 17 + * Mixin that intercepts advancement criterion awards to sync achievements 18 + * to AT Protocol when a player earns a full advancement. 19 + * 20 + * We inject after the award method returns to check if the advancement 21 + * is now fully completed (all criteria satisfied). 22 + */ 23 + @Mixin(PlayerAdvancements.class) 24 + public abstract class PlayerAdvancementsMixin { 25 + 26 + private static final Logger LOGGER = LoggerFactory.getLogger("atproto-connect"); 27 + 28 + @Shadow 29 + private ServerPlayer player; 30 + 31 + /** 32 + * Injects after award to detect when an advancement is fully completed. 33 + * When a player earns all criteria for an advancement, we notify the 34 + * AchievementSyncService to create an AT Protocol record. 35 + */ 36 + @Inject(method = "award", at = @At("RETURN")) 37 + private void onAward(AdvancementHolder advancement, String criterionName, CallbackInfoReturnable<Boolean> cir) { 38 + try { 39 + // Only process if the criterion was actually awarded 40 + if (!cir.getReturnValueZ()) return; 41 + 42 + // Check if the advancement is now fully completed 43 + PlayerAdvancements self = (PlayerAdvancements) (Object) this; 44 + AdvancementProgress progress = self.getOrStartProgress(advancement); 45 + 46 + if (progress != null && progress.isDone()) { 47 + // The advancement is fully completed — notify the sync service 48 + AchievementSyncService.INSTANCE.onAdvancementCompleted( 49 + this.player, 50 + advancement 51 + ); 52 + } 53 + } catch (Exception e) { 54 + LOGGER.error("Error in advancement tracking mixin", e); 55 + } 56 + } 57 + }
+168
src/main/kotlin/com/jollywhoppers/atproto/server/AchievementSyncService.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.advancements.AdvancementHolder 11 + import net.minecraft.advancements.DisplayInfo 12 + import net.minecraft.server.level.ServerPlayer 13 + import org.slf4j.LoggerFactory 14 + import java.time.Instant 15 + import java.util.UUID 16 + import java.util.concurrent.ConcurrentHashMap 17 + 18 + /** 19 + * Syncs Minecraft advancements (achievements) to AT Protocol records. 20 + * 21 + * When a player earns a full advancement, the mixin in PlayerAdvancementTrackerMixin 22 + * calls [onAdvancementCompleted], which creates an achievement record in the 23 + * player's AT Protocol repository. 24 + * 25 + * Privacy controls: 26 + * - Respects the `publicStats` privacy setting from PlayerIdentityStore 27 + * (achievements are considered stats-adjacent data) 28 + * - If publicStats is false, achievements are not synced 29 + * 30 + * Deduplication: 31 + * - Tracks recently synced advancements to avoid duplicates 32 + * - The AT Protocol record key (TID) provides natural dedup since each 33 + * record gets a unique TID, but we still avoid re-syncing the same 34 + * advancement within a session 35 + */ 36 + class AchievementSyncService( 37 + private val recordManager: RecordManager, 38 + private val sessionManager: AtProtoSessionManager, 39 + private val identityStore: PlayerIdentityStore, 40 + ) { 41 + private val logger = LoggerFactory.getLogger("atproto-connect:achievements") 42 + private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) 43 + 44 + // Track advancements already synced this session to avoid duplicates 45 + private val syncedAdvancements = ConcurrentHashMap<UUID, MutableSet<String>>() 46 + 47 + companion object { 48 + private const val COLLECTION_ID = "com.jollywhoppers.minecraft.achievement" 49 + 50 + /** 51 + * Singleton instance for the mixin to call. 52 + * Set by the mod initializer. 53 + */ 54 + lateinit var INSTANCE: AchievementSyncService 55 + } 56 + 57 + /** 58 + * Called by the mixin when a player completes a full advancement. 59 + * This is the entry point from the Java mixin. 60 + */ 61 + fun onAdvancementCompleted(player: ServerPlayer, advancement: AdvancementHolder) { 62 + val uuid = player.uuid 63 + 64 + // Check if linked and authenticated 65 + if (!identityStore.isLinked(uuid) || !sessionManager.hasSession(uuid)) { 66 + return 67 + } 68 + 69 + // Check privacy setting 70 + val privacySettings = identityStore.getPrivacySettings(uuid) 71 + if (privacySettings != null && !privacySettings.first) { 72 + logger.debug("Skipping achievement sync for ${player.name.string}: publicStats is disabled") 73 + return 74 + } 75 + 76 + // Dedup: check if already synced this session 77 + val advancementId = advancement.id().toString() 78 + val playerSynced = syncedAdvancements.getOrPut(uuid) { ConcurrentHashMap.newKeySet() } 79 + if (!playerSynced.add(advancementId)) { 80 + logger.debug("Already synced advancement $advancementId for ${player.name.string}") 81 + return 82 + } 83 + 84 + // Extract advancement details 85 + val display = advancement.value().display().orElse(null) 86 + val advancementName = display?.getTitle()?.string ?: advancementId.substringAfterLast('/') 87 + val advancementDescription = display?.getDescription()?.string ?: "" 88 + val category = extractCategory(advancementId) 89 + val isChallenge = display?.isHidden ?: false 90 + 91 + coroutineScope.launch { 92 + try { 93 + val record = MinecraftAchievementRecord( 94 + player = PlayerReference( 95 + uuid = uuid.toString(), 96 + username = player.name.string, 97 + ), 98 + achievementId = advancementId, 99 + achievementName = advancementName, 100 + achievementDescription = advancementDescription.ifEmpty { null }, 101 + achievedAt = Instant.now().toString(), 102 + category = category, 103 + isChallenge = isChallenge, 104 + ) 105 + 106 + recordManager.createTypedRecord( 107 + playerUuid = uuid, 108 + collection = COLLECTION_ID, 109 + record = record, 110 + ).getOrThrow() 111 + 112 + logger.info("Synced achievement '$advancementName' for ${player.name.string} ($uuid)") 113 + } catch (e: Exception) { 114 + logger.error("Failed to sync achievement '$advancementId' for ${player.name.string} ($uuid)", e) 115 + // Remove from synced set so it can be retried 116 + playerSynced.remove(advancementId) 117 + } 118 + } 119 + } 120 + 121 + /** 122 + * Clears the sync tracking for a player (e.g., on disconnect). 123 + */ 124 + fun clearPlayerTracking(uuid: UUID) { 125 + syncedAdvancements.remove(uuid) 126 + } 127 + 128 + /** 129 + * Extracts the advancement category from the advancement ID. 130 + * Minecraft advancement IDs follow the pattern: minecraft:<category>/<name> 131 + */ 132 + private fun extractCategory(advancementId: String): String { 133 + return when { 134 + advancementId.contains("minecraft:story") -> "story" 135 + advancementId.contains("minecraft:nether") -> "nether" 136 + advancementId.contains("minecraft:end") -> "end" 137 + advancementId.contains("minecraft:adventure") -> "adventure" 138 + advancementId.contains("minecraft:husbandry") -> "husbandry" 139 + else -> { 140 + // Try to extract from the ID pattern 141 + val parts = advancementId.split("/") 142 + if (parts.size > 1) parts[0].substringAfterLast(":") else "other" 143 + } 144 + } 145 + } 146 + 147 + fun shutdown() { 148 + coroutineScope.cancel() 149 + } 150 + 151 + @Serializable 152 + data class PlayerReference( 153 + val uuid: String, 154 + val username: String, 155 + ) 156 + 157 + @Serializable 158 + data class MinecraftAchievementRecord( 159 + @SerialName("\$type") val type: String = COLLECTION_ID, 160 + val player: PlayerReference, 161 + val achievementId: String, 162 + val achievementName: String, 163 + val achievementDescription: String? = null, 164 + val achievedAt: String, 165 + val category: String, 166 + val isChallenge: Boolean = false, 167 + ) 168 + }
+2 -1
src/main/resources/atproto-connect.mixins.json
··· 3 3 "package": "com.jollywhoppers.mixin", 4 4 "compatibilityLevel": "JAVA_21", 5 5 "mixins": [ 6 - "ExampleMixin" 6 + "ExampleMixin", 7 + "PlayerAdvancementsMixin" 7 8 ], 8 9 "injectors": { 9 10 "defaultRequire": 1