diff --git a/MinecraftClient/Achievement.cs b/MinecraftClient/Achievement.cs
new file mode 100644
index 00000000..760e4054
--- /dev/null
+++ b/MinecraftClient/Achievement.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace MinecraftClient
+{
+ ///
+ /// The type of an achievement or advancement.
+ ///
+ public enum AchievementType
+ {
+ Task,
+ Challenge,
+ Goal,
+ Legacy
+ }
+
+ ///
+ /// Represents a Minecraft achievement (pre-1.12) or advancement (1.12+).
+ ///
+ /// Resource identifier, e.g. "minecraft:story/root" or "achievement.openInventory"
+ /// Display title (null for legacy achievements without display info)
+ /// Display description (null for legacy achievements without display info)
+ /// The frame type / achievement category
+ /// Whether this advancement is hidden in the UI
+ /// Whether all requirements have been met
+ /// OR-groups of criterion names; all groups must be satisfied
+ /// Per-criterion completion status
+ public record Achievement(
+ string Id,
+ string? Title,
+ string? Description,
+ AchievementType Type,
+ bool IsHidden,
+ bool IsCompleted,
+ IReadOnlyList> Requirements,
+ IReadOnlyDictionary CriteriaProgress);
+}
diff --git a/MinecraftClient/Commands/AchievementCommand.cs b/MinecraftClient/Commands/AchievementCommand.cs
new file mode 100644
index 00000000..ee99c4d7
--- /dev/null
+++ b/MinecraftClient/Commands/AchievementCommand.cs
@@ -0,0 +1,106 @@
+using System.Linq;
+using System.Text;
+using Brigadier.NET;
+using Brigadier.NET.Builder;
+using MinecraftClient.CommandHandler;
+
+namespace MinecraftClient.Commands
+{
+ public class AchievementCommand : Command
+ {
+ public override string CmdName => "achievement";
+ public override string CmdUsage => "achievement ";
+ public override string CmdDesc => Translations.cmd_achievement_desc;
+
+ public override void RegisterCommand(CommandDispatcher dispatcher)
+ {
+ dispatcher.Register(l => l.Literal("help")
+ .Then(l => l.Literal(CmdName)
+ .Executes(r => GetUsage(r.Source, string.Empty))
+ .Then(l => l.Literal("list")
+ .Executes(r => GetUsage(r.Source, "list")))
+ .Then(l => l.Literal("locked")
+ .Executes(r => GetUsage(r.Source, "locked")))
+ .Then(l => l.Literal("unlocked")
+ .Executes(r => GetUsage(r.Source, "unlocked")))
+ )
+ );
+
+ dispatcher.Register(l => l.Literal(CmdName)
+ .Executes(r => ListAchievements(r.Source, null))
+ .Then(l => l.Literal("list")
+ .Executes(r => ListAchievements(r.Source, null)))
+ .Then(l => l.Literal("locked")
+ .Executes(r => ListAchievements(r.Source, false)))
+ .Then(l => l.Literal("unlocked")
+ .Executes(r => ListAchievements(r.Source, true)))
+ .Then(l => l.Literal("_help")
+ .Executes(r => GetUsage(r.Source, string.Empty))
+ .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
+ );
+ }
+
+ private int GetUsage(CmdResult r, string? cmd)
+ {
+ return r.SetAndReturn(cmd switch
+ {
+#pragma warning disable format
+ "list" => GetCmdDescTranslated(),
+ "locked" => GetCmdDescTranslated(),
+ "unlocked" => GetCmdDescTranslated(),
+ _ => GetCmdDescTranslated(),
+#pragma warning restore format
+ });
+ }
+
+ /// null = all, true = unlocked only, false = locked only
+ private static int ListAchievements(CmdResult r, bool? completed)
+ {
+ McClient handler = CmdResult.currentHandler!;
+
+ Achievement[] items = completed switch
+ {
+ true => handler.GetUnlockedAchievements(),
+ false => handler.GetLockedAchievements(),
+ null => handler.GetAchievements()
+ };
+
+ if (items.Length == 0)
+ {
+ string msg = completed switch
+ {
+ true => Translations.cmd_achievement_none_unlocked,
+ false => Translations.cmd_achievement_none_locked,
+ _ => Translations.cmd_achievement_none
+ };
+ return r.SetAndReturn(CmdResult.Status.Done, msg);
+ }
+
+ string header = completed switch
+ {
+ true => Translations.cmd_achievement_header_unlocked,
+ false => Translations.cmd_achievement_header_locked,
+ _ => Translations.cmd_achievement_header
+ };
+
+ StringBuilder sb = new();
+ sb.AppendLine(header);
+
+ foreach (Achievement a in items.OrderBy(static a => a.Id))
+ {
+ string status = a.IsCompleted
+ ? Translations.cmd_achievement_done
+ : Translations.cmd_achievement_todo;
+
+ string display = a.Title is not null
+ ? string.Format(Translations.cmd_achievement_entry_titled, status, a.Title, a.Id, a.Type)
+ : string.Format(Translations.cmd_achievement_entry, status, a.Id, a.Type);
+
+ sb.AppendLine(display);
+ }
+
+ handler.Log.Info(sb.ToString().TrimEnd());
+ return r.SetAndReturn(CmdResult.Status.Done);
+ }
+ }
+}
diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs
index 24069342..3eabfbd8 100644
--- a/MinecraftClient/McClient.cs
+++ b/MinecraftClient/McClient.cs
@@ -45,11 +45,14 @@ namespace MinecraftClient
private readonly Queue threadTasks = new();
private readonly Lock threadTasksLock = new();
private readonly Lock recipeBookLock = new();
+ private readonly Lock achievementsLock = new();
private readonly List bots = new();
private static readonly List botsOnHold = new();
private static readonly Dictionary inventories = new();
private readonly Dictionary unlockedRecipes = new(StringComparer.Ordinal);
+ private readonly Dictionary achievements = new(StringComparer.Ordinal);
+ private string? activeAdvancementTab;
private readonly Dictionary> registeredBotPluginChannels = new();
private readonly List registeredServerPluginChannels = new();
@@ -1353,6 +1356,42 @@ namespace MinecraftClient
}
}
+ ///
+ /// Get all achievements/advancements known to the client.
+ ///
+ /// Snapshot of all achievements
+ public Achievement[] GetAchievements()
+ {
+ lock (achievementsLock)
+ {
+ return [.. achievements.Values];
+ }
+ }
+
+ ///
+ /// Get only completed achievements/advancements.
+ ///
+ /// Snapshot of completed achievements
+ public Achievement[] GetUnlockedAchievements()
+ {
+ lock (achievementsLock)
+ {
+ return achievements.Values.Where(static a => a.IsCompleted).ToArray();
+ }
+ }
+
+ ///
+ /// Get only incomplete achievements/advancements.
+ ///
+ /// Snapshot of locked achievements
+ public Achievement[] GetLockedAchievements()
+ {
+ lock (achievementsLock)
+ {
+ return achievements.Values.Where(static a => !a.IsCompleted).ToArray();
+ }
+ }
+
///
/// Get all Entities
///
@@ -4139,6 +4178,67 @@ namespace MinecraftClient
}
}
+ public void OnAchievementsUpdate(IReadOnlyList added, IReadOnlyList removedIds, bool reset)
+ {
+ lock (achievementsLock)
+ {
+ if (reset)
+ achievements.Clear();
+
+ // Remove entries
+ foreach (string id in removedIds)
+ achievements.Remove(id);
+
+ // Add/update entries. For progress-only updates (no definition),
+ // merge with existing definition if available.
+ foreach (Achievement entry in added)
+ {
+ if (entry.Title is null && achievements.TryGetValue(entry.Id, out Achievement? existing))
+ {
+ // Progress-only update - merge with existing definition
+ bool isCompleted = ComputeAchievementCompleted(existing.Requirements, entry.CriteriaProgress);
+ achievements[entry.Id] = existing with { IsCompleted = isCompleted, CriteriaProgress = entry.CriteriaProgress };
+ }
+ else
+ {
+ achievements[entry.Id] = entry;
+ }
+ }
+ }
+
+ DispatchBotEvent(bot => bot.OnAchievementUpdate(added, removedIds, reset));
+ }
+
+ public void OnSelectAdvancementTab(string? tabId)
+ {
+ activeAdvancementTab = tabId;
+ }
+
+ ///
+ /// Compute whether an achievement is completed based on AND-of-ORs requirements.
+ ///
+ private static bool ComputeAchievementCompleted(IReadOnlyList> requirements, IReadOnlyDictionary criteria)
+ {
+ if (requirements.Count == 0)
+ return true;
+
+ foreach (IReadOnlyList group in requirements)
+ {
+ bool groupSatisfied = false;
+ foreach (string criterion in group)
+ {
+ if (criteria.TryGetValue(criterion, out bool done) && done)
+ {
+ groupSatisfied = true;
+ break;
+ }
+ }
+ if (!groupSatisfied)
+ return false;
+ }
+ return true;
+ }
+
///
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index bc9537cd..2d934561 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -3132,6 +3132,14 @@ namespace MinecraftClient.Protocol.Handlers
case PacketTypesIn.RecipeBookSettings:
break;
+ case PacketTypesIn.Advancements:
+ HandleAdvancements(packetData);
+ break;
+
+ case PacketTypesIn.SelectAdvancementTab:
+ HandleSelectAdvancementTab(packetData);
+ break;
+
default:
return false; //Ignored packet
}
@@ -3139,6 +3147,189 @@ namespace MinecraftClient.Protocol.Handlers
return true; //Packet processed
}
+ ///
+ /// Handle the Advancements packet (1.12+).
+ /// Also handles the Statistics packet for pre-1.12 legacy achievements.
+ ///
+ private void HandleAdvancements(Queue packetData)
+ {
+ bool reset = dataTypes.ReadNextBool(packetData);
+
+ // --- Added advancements ---
+ int addedCount = dataTypes.ReadNextVarInt(packetData);
+ var added = new List(addedCount);
+ var addedDefinitions = new Dictionary> requirements)>(addedCount);
+
+ for (int i = 0; i < addedCount; i++)
+ {
+ string id = dataTypes.ReadNextString(packetData);
+
+ // Parent
+ bool hasParent = dataTypes.ReadNextBool(packetData);
+ if (hasParent)
+ dataTypes.ReadNextString(packetData); // parentId - read and discard
+
+ // Display
+ string? title = null;
+ string? description = null;
+ var type = AchievementType.Task;
+ bool isHidden = false;
+
+ bool hasDisplay = dataTypes.ReadNextBool(packetData);
+ if (hasDisplay)
+ {
+ title = dataTypes.ReadNextChat(packetData);
+ description = dataTypes.ReadNextChat(packetData);
+ dataTypes.ReadNextItemSlot(packetData, itemPalette); // icon - read and discard
+
+ int frameType = dataTypes.ReadNextVarInt(packetData);
+ type = frameType switch
+ {
+ 1 => AchievementType.Challenge,
+ 2 => AchievementType.Goal,
+ _ => AchievementType.Task
+ };
+
+ int flags = dataTypes.ReadNextInt(packetData);
+ isHidden = (flags & 0x04) != 0;
+ if ((flags & 0x01) != 0)
+ dataTypes.ReadNextString(packetData); // background texture - read and discard
+
+ dataTypes.ReadNextFloat(packetData); // x
+ dataTypes.ReadNextFloat(packetData); // y
+ }
+
+ // Criteria and requirements differ by version
+ var requirements = new List>();
+
+ if (protocolVersion < MC_1_20_6_Version)
+ {
+ // Builder-based: criteria names list, then requirements
+ int criteriaCount = dataTypes.ReadNextVarInt(packetData);
+ for (int c = 0; c < criteriaCount; c++)
+ dataTypes.ReadNextString(packetData); // criterion name only, no trigger data
+
+ int reqGroupCount = dataTypes.ReadNextVarInt(packetData);
+ for (int g = 0; g < reqGroupCount; g++)
+ {
+ int groupSize = dataTypes.ReadNextVarInt(packetData);
+ var group = new List(groupSize);
+ for (int s = 0; s < groupSize; s++)
+ group.Add(dataTypes.ReadNextString(packetData));
+ requirements.Add(group);
+ }
+ }
+ else
+ {
+ // AdvancementHolder-based (1.20.6+): requirements only, then sendsTelemetryEvent
+ int reqGroupCount = dataTypes.ReadNextVarInt(packetData);
+ for (int g = 0; g < reqGroupCount; g++)
+ {
+ int groupSize = dataTypes.ReadNextVarInt(packetData);
+ var group = new List(groupSize);
+ for (int s = 0; s < groupSize; s++)
+ group.Add(dataTypes.ReadNextString(packetData));
+ requirements.Add(group);
+ }
+
+ dataTypes.ReadNextBool(packetData); // sendsTelemetryEvent
+ }
+
+ addedDefinitions[id] = (title, description, type, isHidden, requirements);
+ }
+
+ // --- Removed advancement IDs ---
+ int removedCount = dataTypes.ReadNextVarInt(packetData);
+ var removedIds = new List(removedCount);
+ for (int i = 0; i < removedCount; i++)
+ removedIds.Add(dataTypes.ReadNextString(packetData));
+
+ // --- Progress updates ---
+ int progressCount = dataTypes.ReadNextVarInt(packetData);
+ var progressMap = new Dictionary>(progressCount);
+
+ for (int i = 0; i < progressCount; i++)
+ {
+ string id = dataTypes.ReadNextString(packetData);
+ int criteriaEntries = dataTypes.ReadNextVarInt(packetData);
+ var criteria = new Dictionary(criteriaEntries);
+
+ for (int c = 0; c < criteriaEntries; c++)
+ {
+ string criterionName = dataTypes.ReadNextString(packetData);
+ bool isDone = dataTypes.ReadNextBool(packetData);
+ if (isDone)
+ dataTypes.ReadNextLong(packetData); // epochMs - read and discard
+ criteria[criterionName] = isDone;
+ }
+
+ progressMap[id] = criteria;
+ }
+
+ // showAdvancements boolean added in 1.21.11+
+ if (protocolVersion >= MC_1_21_11_Version)
+ dataTypes.ReadNextBool(packetData); // showAdvancements - read and discard
+
+ // Build Achievement records from definitions + progress
+ foreach (var (id, def) in addedDefinitions)
+ {
+ progressMap.TryGetValue(id, out var criteria);
+ criteria ??= new Dictionary();
+
+ bool isCompleted = ComputeAdvancementCompleted(def.requirements, criteria);
+
+ var readOnlyReqs = def.requirements.ConvertAll>(static g => g.AsReadOnly());
+ added.Add(new Achievement(id, def.title, def.description, def.type, def.isHidden, isCompleted, readOnlyReqs.AsReadOnly(), criteria));
+ }
+
+ // Also build Achievement records for progress-only updates (no definition change)
+ var progressOnly = new List();
+ foreach (var (id, criteria) in progressMap)
+ {
+ if (!addedDefinitions.ContainsKey(id))
+ progressOnly.Add(new Achievement(id, null, null, AchievementType.Task, false, false, [], criteria));
+ }
+
+ handler.OnAchievementsUpdate([.. added, .. progressOnly], removedIds, reset);
+ }
+
+ ///
+ /// Compute whether an advancement is completed based on AND-of-ORs requirements.
+ ///
+ private static bool ComputeAdvancementCompleted(List> requirements, Dictionary criteria)
+ {
+ // Zero requirements = automatically done
+ if (requirements.Count == 0)
+ return true;
+
+ // Each OR-group must have at least one satisfied criterion
+ foreach (var group in requirements)
+ {
+ bool groupSatisfied = false;
+ foreach (string criterion in group)
+ {
+ if (criteria.TryGetValue(criterion, out bool done) && done)
+ {
+ groupSatisfied = true;
+ break;
+ }
+ }
+ if (!groupSatisfied)
+ return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Handle the SelectAdvancementTab packet.
+ ///
+ private void HandleSelectAdvancementTab(Queue packetData)
+ {
+ bool hasTab = dataTypes.ReadNextBool(packetData);
+ string? tabId = hasTab ? dataTypes.ReadNextString(packetData) : null;
+ handler.OnSelectAdvancementTab(tabId);
+ }
+
private void HandleUnlockRecipes(Queue packetData)
{
int action = dataTypes.ReadNextVarInt(packetData);
diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs
index 81a4a056..9bfa44e8 100644
--- a/MinecraftClient/Protocol/IMinecraftComHandler.cs
+++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs
@@ -530,6 +530,20 @@ namespace MinecraftClient.Protocol
/// Recipe identifiers to remove
public void OnRecipeBookRemove(string[] recipeIds);
+ ///
+ /// Called when achievement/advancement data is received from the server.
+ ///
+ /// Achievements that were added or updated
+ /// IDs of achievements that were removed
+ /// True if all existing state should be cleared before applying
+ public void OnAchievementsUpdate(IReadOnlyList added, IReadOnlyList removedIds, bool reset);
+
+ ///
+ /// Called when the server selects an advancement tab.
+ ///
+ /// The tab identifier, or null if no tab is selected
+ public void OnSelectAdvancementTab(string? tabId);
+
///
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom
diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs
index 022e9cad..b77b0f39 100644
--- a/MinecraftClient/Resources/Translations/Translations.Designer.cs
+++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs
@@ -7174,5 +7174,104 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.minimap.position_set", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to list achievements/advancements from the server..
+ ///
+ internal static string cmd_achievement_desc {
+ get {
+ return ResourceManager.GetString("cmd.achievement.desc", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No achievements/advancements received yet..
+ ///
+ internal static string cmd_achievement_none {
+ get {
+ return ResourceManager.GetString("cmd.achievement.none", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No completed achievements/advancements..
+ ///
+ internal static string cmd_achievement_none_unlocked {
+ get {
+ return ResourceManager.GetString("cmd.achievement.none_unlocked", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No incomplete achievements/advancements..
+ ///
+ internal static string cmd_achievement_none_locked {
+ get {
+ return ResourceManager.GetString("cmd.achievement.none_locked", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Achievements/Advancements:.
+ ///
+ internal static string cmd_achievement_header {
+ get {
+ return ResourceManager.GetString("cmd.achievement.header", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Completed achievements/advancements:.
+ ///
+ internal static string cmd_achievement_header_unlocked {
+ get {
+ return ResourceManager.GetString("cmd.achievement.header_unlocked", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Incomplete achievements/advancements:.
+ ///
+ internal static string cmd_achievement_header_locked {
+ get {
+ return ResourceManager.GetString("cmd.achievement.header_locked", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to [DONE].
+ ///
+ internal static string cmd_achievement_done {
+ get {
+ return ResourceManager.GetString("cmd.achievement.done", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to [TODO].
+ ///
+ internal static string cmd_achievement_todo {
+ get {
+ return ResourceManager.GetString("cmd.achievement.todo", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} {1} ({2}) [{3}].
+ ///
+ internal static string cmd_achievement_entry_titled {
+ get {
+ return ResourceManager.GetString("cmd.achievement.entry_titled", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0} {1} [{2}].
+ ///
+ internal static string cmd_achievement_entry {
+ get {
+ return ResourceManager.GetString("cmd.achievement.entry", resourceCulture);
+ }
+ }
}
}
diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx
index 7c883622..48a07470 100644
--- a/MinecraftClient/Resources/Translations/Translations.resx
+++ b/MinecraftClient/Resources/Translations/Translations.resx
@@ -2527,4 +2527,37 @@ see item details.
Minimap position set to: {0}
+
+ list achievements/advancements from the server.
+
+
+ No achievements/advancements received yet.
+
+
+ No completed achievements/advancements.
+
+
+ No incomplete achievements/advancements.
+
+
+ Achievements/Advancements:
+
+
+ Completed achievements/advancements:
+
+
+ Incomplete achievements/advancements:
+
+
+ [DONE]
+
+
+ [TODO]
+
+
+ {0} {1} ({2}) [{3}]
+
+
+ {0} {1} [{2}]
+
diff --git a/MinecraftClient/Scripting/ChatBot.cs b/MinecraftClient/Scripting/ChatBot.cs
index f62e1377..422fc1a4 100644
--- a/MinecraftClient/Scripting/ChatBot.cs
+++ b/MinecraftClient/Scripting/ChatBot.cs
@@ -514,6 +514,14 @@ namespace MinecraftClient.Scripting
/// The block
public virtual void OnBlockChange(Location location, Block block) { }
+ ///
+ /// Called when achievement/advancement data is updated.
+ ///
+ /// Achievements that were added or updated
+ /// IDs of achievements that were removed
+ /// Whether the achievement state was fully reset before this update
+ public virtual void OnAchievementUpdate(IReadOnlyList updated, IReadOnlyList removedIds, bool reset) { }
+
/* =================================================================== */
/* ToolBox - Methods below might be useful while creating your bot. */
/* You should not need to interact with other classes of the program. */
@@ -1120,6 +1128,33 @@ namespace MinecraftClient.Scripting
return Handler.GetEntities();
}
+ ///
+ /// Get all achievements/advancements.
+ ///
+ /// Snapshot of all achievements
+ protected Achievement[] GetAchievements()
+ {
+ return Handler.GetAchievements();
+ }
+
+ ///
+ /// Get only completed achievements/advancements.
+ ///
+ /// Snapshot of unlocked achievements
+ protected Achievement[] GetUnlockedAchievements()
+ {
+ return Handler.GetUnlockedAchievements();
+ }
+
+ ///
+ /// Get only incomplete achievements/advancements.
+ ///
+ /// Snapshot of locked achievements
+ protected Achievement[] GetLockedAchievements()
+ {
+ return Handler.GetLockedAchievements();
+ }
+
///
/// Get all players Latency
///