From 6dc42d9bd1f077a4f9523722ed5437864964dbaf Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 20:10:15 +0000
Subject: [PATCH 1/9] Initial plan
From 65ef3dde6b07d97aab65226e70665585a76f6c97 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 20:22:31 +0000
Subject: [PATCH 2/9] Implement unified achievements feature: data model,
protocol handling, state management, ChatBot API, and /achievement command
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/9db483a8-4a5f-47b1-a6f4-30b6e39075bd
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
---
MinecraftClient/Achievement.cs | 36 ++++
.../Commands/AchievementCommand.cs | 106 ++++++++++
MinecraftClient/McClient.cs | 100 +++++++++
.../Protocol/Handlers/Protocol18.cs | 191 ++++++++++++++++++
.../Protocol/IMinecraftComHandler.cs | 14 ++
.../Translations/Translations.Designer.cs | 99 +++++++++
.../Resources/Translations/Translations.resx | 33 +++
MinecraftClient/Scripting/ChatBot.cs | 35 ++++
8 files changed, 614 insertions(+)
create mode 100644 MinecraftClient/Achievement.cs
create mode 100644 MinecraftClient/Commands/AchievementCommand.cs
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
///
From 7b3e5ee49215ddc76b5addf7db599e43267f8852 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 20:27:12 +0000
Subject: [PATCH 3/9] Address code review: eliminate unnecessary allocation in
progress-only updates
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/9db483a8-4a5f-47b1-a6f4-30b6e39075bd
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
---
MinecraftClient/Protocol/Handlers/Protocol18.cs | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index 2d934561..bb603f9b 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -3283,14 +3283,13 @@ namespace MinecraftClient.Protocol.Handlers
}
// 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));
+ added.Add(new Achievement(id, null, null, AchievementType.Task, false, false, [], criteria));
}
- handler.OnAchievementsUpdate([.. added, .. progressOnly], removedIds, reset);
+ handler.OnAchievementsUpdate(added, removedIds, reset);
}
///
From 5705df43bdb8f91daa29256eeb0821ee11fc5e6c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 21:07:17 +0000
Subject: [PATCH 4/9] Fix Advancements packet parsing: sendsTelemetryEvent
added in 1.20, deduplicate requirements reading
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/5da0ec37-35e2-4aae-b165-66ddd82df985
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
---
.../Protocol/Handlers/Protocol18.cs | 38 +++++++------------
1 file changed, 14 insertions(+), 24 deletions(-)
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index bb603f9b..73c47a59 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -3204,37 +3204,27 @@ namespace MinecraftClient.Protocol.Handlers
if (protocolVersion < MC_1_20_6_Version)
{
- // Builder-based: criteria names list, then requirements
+ // Builder-based (pre-1.20.6): 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
+
+ // Requirements (all versions)
+ int reqGroupCount = dataTypes.ReadNextVarInt(packetData);
+ for (int g = 0; g < reqGroupCount; g++)
{
- // 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
+ 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);
}
+ // sendsTelemetryEvent (added in 1.20, present in all versions since)
+ if (protocolVersion >= MC_1_20_Version)
+ dataTypes.ReadNextBool(packetData);
+
addedDefinitions[id] = (title, description, type, isHidden, requirements);
}
From 0f3289dfdffa8156ae4a1508d52041dd2f2d3984 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 29 Mar 2026 21:16:45 +0000
Subject: [PATCH 5/9] Fix criteria version boundary: criteria list removed from
wire format in MC 1.20.2, not 1.20.6
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/5da0ec37-35e2-4aae-b165-66ddd82df985
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
---
MinecraftClient/Protocol/Handlers/Protocol18.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index 73c47a59..ac6b3b4f 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -3202,9 +3202,9 @@ namespace MinecraftClient.Protocol.Handlers
// Criteria and requirements differ by version
var requirements = new List>();
- if (protocolVersion < MC_1_20_6_Version)
+ if (protocolVersion < MC_1_20_2_Version)
{
- // Builder-based (pre-1.20.6): criteria names list, then requirements
+ // Builder-based (pre-1.20.2): 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
From c55d32bb7009156723f36f9d77901ac54612c8f2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 08:03:56 +0000
Subject: [PATCH 6/9] Diagnose root cause of 26.1 RecipeBookAdd crash
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/47b0c937-1491-4216-8ee0-1aca866e99ab
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
---
server.pid | 1 +
1 file changed, 1 insertion(+)
create mode 100644 server.pid
diff --git a/server.pid b/server.pid
new file mode 100644
index 00000000..7b6ceee2
--- /dev/null
+++ b/server.pid
@@ -0,0 +1 @@
+5586
From 962c8b1ab24776773a28b10eb5eadeeea8fae580 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 30 Mar 2026 08:09:33 +0000
Subject: [PATCH 7/9] Fix 26.1 RecipeBookAdd crash: update SlotDisplay registry
IDs for 26.1
MC 26.1 changed the minecraft:slot_display registry, inserting 3 new
types (with_any_potion, only_with_component, dyed) and shifting all
existing IDs. This caused MCC to misparse recipe display data, leading
to a Queue empty crash in SkipItemHolderSet.
Add version-gated ReadSlotDisplayLabel with correct 26.1 type mapping
and reader methods for the 3 new slot display types.
Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/47b0c937-1491-4216-8ee0-1aca866e99ab
Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
---
.gitignore | 1 +
.../Protocol/Handlers/Protocol18.cs | 51 +++++++++++++++++++
server.pid | 1 -
3 files changed, 52 insertions(+), 1 deletion(-)
delete mode 100644 server.pid
diff --git a/.gitignore b/.gitignore
index 91c3a8b6..d0f86370 100644
--- a/.gitignore
+++ b/.gitignore
@@ -437,3 +437,4 @@ FodyWeavers.xsd
/.specstory/
/.vscode/settings.json
/Sentry/
+server.pid
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index ac6b3b4f..7032bcbd 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -3470,6 +3470,29 @@ namespace MinecraftClient.Protocol.Handlers
private string ReadSlotDisplayLabel(Queue packetData)
{
int slotDisplayType = dataTypes.ReadNextVarInt(packetData);
+
+ // 26.1 changed the slot display registry order, inserting 3 new types:
+ // Pre-26.1: 0=empty, 1=any_fuel, 2=item, 3=item_stack, 4=tag, 5=smithing_trim, 6=with_remainder, 7=composite
+ // 26.1+: 0=empty, 1=any_fuel, 2=with_any_potion, 3=only_with_component, 4=item, 5=item_stack, 6=tag, 7=dyed, 8=smithing_trim, 9=with_remainder, 10=composite
+ if (protocolVersion >= MC_26_1_Version)
+ {
+ return slotDisplayType switch
+ {
+ 0 => "Empty",
+ 1 => "Any Fuel",
+ 2 => ReadWithAnyPotionSlotDisplayLabel(packetData),
+ 3 => ReadOnlyWithComponentSlotDisplayLabel(packetData),
+ 4 => Item.GetTypeString(itemPalette.FromId(dataTypes.ReadNextVarInt(packetData))),
+ 5 => dataTypes.ReadNextItemSlot(packetData, itemPalette)?.GetTypeString() ?? "Empty",
+ 6 => "#" + dataTypes.ReadNextString(packetData),
+ 7 => ReadDyedSlotDisplayLabel(packetData),
+ 8 => ReadSmithingTrimSlotDisplayLabel(packetData),
+ 9 => ReadWithRemainderSlotDisplayLabel(packetData),
+ 10 => ReadCompositeSlotDisplayLabel(packetData),
+ _ => $"slot_display_{slotDisplayType}",
+ };
+ }
+
return slotDisplayType switch
{
0 => "Empty",
@@ -3484,6 +3507,34 @@ namespace MinecraftClient.Protocol.Handlers
};
}
+ ///
+ /// Reads a with_any_potion slot display (26.1+): contains a nested SlotDisplay.
+ ///
+ private string ReadWithAnyPotionSlotDisplayLabel(Queue packetData)
+ {
+ return ReadSlotDisplayLabel(packetData);
+ }
+
+ ///
+ /// Reads an only_with_component slot display (26.1+): contains a nested SlotDisplay and a DataComponentType VarInt ID.
+ ///
+ private string ReadOnlyWithComponentSlotDisplayLabel(Queue packetData)
+ {
+ string sourceLabel = ReadSlotDisplayLabel(packetData);
+ _ = dataTypes.ReadNextVarInt(packetData); // DataComponentType registry id
+ return sourceLabel;
+ }
+
+ ///
+ /// Reads a dyed slot display (26.1+): contains two nested SlotDisplays (dye + target).
+ ///
+ private string ReadDyedSlotDisplayLabel(Queue packetData)
+ {
+ _ = ReadSlotDisplayLabel(packetData); // dye
+ string targetLabel = ReadSlotDisplayLabel(packetData); // target
+ return targetLabel;
+ }
+
private string ReadSmithingTrimSlotDisplayLabel(Queue packetData)
{
string baseLabel = ReadSlotDisplayLabel(packetData);
diff --git a/server.pid b/server.pid
deleted file mode 100644
index 7b6ceee2..00000000
--- a/server.pid
+++ /dev/null
@@ -1 +0,0 @@
-5586
From 0881cbaa1ca0af7a4d4876cc3fad03a1c4eb1cc2 Mon Sep 17 00:00:00 2001
From: milutinke
Date: Mon, 30 Mar 2026 17:25:08 +0200
Subject: [PATCH 8/9] Fix legacy achievements and add test harness
---
.../scripts/ensure_offline_server.sh | 19 +-
.../scripts/prepare_offline_mcc_config.sh | 10 +-
.../scripts/run_achievements_matrix.sh | 157 +++++++
.../scripts/run_achievements_test.sh | 443 ++++++++++++++++++
.../scripts/summarize_achievements_matrix.sh | 57 +++
MinecraftClient/LegacyAchievementCatalog.cs | 53 +++
.../Protocol/Handlers/Protocol18.cs | 49 +-
7 files changed, 785 insertions(+), 3 deletions(-)
create mode 100755 .skills/mcc-integration-testing/scripts/run_achievements_matrix.sh
create mode 100755 .skills/mcc-integration-testing/scripts/run_achievements_test.sh
create mode 100755 .skills/mcc-integration-testing/scripts/summarize_achievements_matrix.sh
create mode 100644 MinecraftClient/LegacyAchievementCatalog.cs
diff --git a/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh b/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh
index 5e67687d..1e348445 100755
--- a/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh
+++ b/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh
@@ -6,6 +6,14 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/tools/mcc-env.sh"
+sed_in_place() {
+ if [[ "$(uname)" == "Darwin" ]]; then
+ sed -i '' "$@"
+ else
+ sed -i "$@"
+ fi
+}
+
VERSION="${1:-1.21.11-Vanilla}"
SERVER_DIR="${MCC_SERVERS:?}/$VERSION"
PROPS_FILE="$SERVER_DIR/server.properties"
@@ -49,6 +57,15 @@ wait_for_server_stop() {
sleep 1
((elapsed += 1))
done
+
+ # Legacy servers can leave the tmux session around after stdin stop.
+ # Fall back to force-killing the session so the harness can continue.
+ mc-kill "$VERSION" >/dev/null 2>&1 || true
+
+ if ! server_running; then
+ return 0
+ fi
+
echo "Timed out waiting for $VERSION to stop" >&2
return 1
}
@@ -58,7 +75,7 @@ upsert_property() {
local value="$2"
if grep -Eq "^${key}=" "$PROPS_FILE"; then
- sed -i "s#^${key}=.*#${key}=${value}#" "$PROPS_FILE"
+ sed_in_place "s#^${key}=.*#${key}=${value}#" "$PROPS_FILE"
else
printf '%s=%s\n' "$key" "$value" >> "$PROPS_FILE"
fi
diff --git a/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh b/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh
index f36129fa..9eae53b3 100644
--- a/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh
+++ b/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh
@@ -1,6 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
+sed_in_place() {
+ if [[ "$(uname)" == "Darwin" ]]; then
+ sed -i '' "$@"
+ else
+ sed -i "$@"
+ fi
+}
+
if [[ $# -lt 3 || $# -gt 4 ]]; then
echo "Usage: $0 [login]" >&2
exit 1
@@ -28,7 +36,7 @@ fi
cp "$TEMPLATE_INI" "$OUTPUT_INI"
-sed -i \
+sed_in_place \
-e "s#^Account = .*#Account = { Login = \"$LOGIN_NAME\", Password = \"$PASSWORD_VALUE\" }#" \
-e "s#^AccountType = .*#AccountType = \"$ACCOUNT_TYPE\"#" \
-e "s#^MinecraftVersion = \"[^\"]*\"\\(.*\\)\$#MinecraftVersion = \"$MC_VERSION\"\\1#" \
diff --git a/.skills/mcc-integration-testing/scripts/run_achievements_matrix.sh b/.skills/mcc-integration-testing/scripts/run_achievements_matrix.sh
new file mode 100755
index 00000000..45629525
--- /dev/null
+++ b/.skills/mcc-integration-testing/scripts/run_achievements_matrix.sh
@@ -0,0 +1,157 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+# shellcheck source=tools/mcc-env.sh
+source "$REPO_ROOT/tools/mcc-env.sh"
+
+RUN_ROOT="${TMPDIR:-/tmp}/mcc-achievements/matrix"
+RUN_ID="$(date +%Y%m%d-%H%M%S)"
+MATRIX_DIR="$RUN_ROOT/$RUN_ID"
+RESULTS_TSV="$MATRIX_DIR/results.tsv"
+BUILD_LOG="$MATRIX_DIR/build.log"
+REPORT_MD="$MATRIX_DIR/report.md"
+PRECHECK_TXT="$MATRIX_DIR/preflight.txt"
+
+mkdir -p "$MATRIX_DIR"
+
+write_row() {
+ local fields=("$@")
+
+ while (( ${#fields[@]} < 14 )); do
+ fields+=("")
+ done
+
+ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
+ "${fields[0]}" "${fields[1]}" "${fields[2]}" "${fields[3]}" "${fields[4]}" "${fields[5]}" "${fields[6]}" \
+ "${fields[7]}" "${fields[8]}" "${fields[9]}" "${fields[10]}" "${fields[11]}" "${fields[12]}" \
+ "${fields[13]}" >> "$RESULTS_TSV"
+}
+
+resolve_server_dir() {
+ local version="$1"
+ local candidate
+
+ for candidate in "$version" "$version-Vanilla"; do
+ if [[ -d "$MCC_SERVERS/$candidate" ]]; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ done
+
+ return 1
+}
+
+run_version() {
+ local version="$1"
+ local profile="$2"
+ local family="$3"
+ local server_dir="$4"
+ local summary_env
+
+ if bash "$SCRIPT_DIR/run_achievements_test.sh" --no-build "$server_dir" "$version" "$profile"; then
+ :
+ fi
+
+ summary_env="${TMPDIR:-/tmp}/mcc-achievements/$server_dir/latest/summary.env"
+ if [[ ! -f "$summary_env" ]]; then
+ write_row "$version" "$server_dir" "unknown" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
+ "Summary file was not produced." "" "" ""
+ return
+ fi
+
+ # shellcheck disable=SC1090
+ source "$summary_env"
+
+ write_row "$VERSION" "$SERVER_DIR" "$PORT" "$FAMILY" "$INITIAL_STATUS" "$GRANT_STATUS" "$REVOKE_STATUS" \
+ "$API_STATUS" "$VERDICT" "$NOTE" "$RUN_DIR" "$MCC_LOG" "$COPIED_SERVER_LOG" "$COMMAND_LOG"
+}
+
+{
+ printf 'MCC_SERVERS=%s\n' "$MCC_SERVERS"
+ printf 'RUN_DIR=%s\n' "$MATRIX_DIR"
+ printf 'DATE=%s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
+} > "$PRECHECK_TXT"
+
+printf 'Version\tServerDir\tPort\tFamily\tInitial\tGrant\tRevoke\tAPI\tVerdict\tNote\tRunDir\tMccLog\tServerLog\tCommandLog\n' > "$RESULTS_TSV"
+
+JAVA_OK="yes"
+TMUX_OK="yes"
+DOTNET_OK="yes"
+BUILD_OK="yes"
+
+if ! command -v dotnet >/dev/null 2>&1; then
+ DOTNET_OK="no"
+fi
+
+if ! command -v java >/dev/null 2>&1 || ! java -version >/dev/null 2>&1; then
+ JAVA_OK="no"
+fi
+
+if ! command -v tmux >/dev/null 2>&1; then
+ TMUX_OK="no"
+fi
+
+if [[ "$DOTNET_OK" == "yes" ]]; then
+ if ! dotnet build "$REPO_ROOT/MinecraftClient.sln" -c Release > "$BUILD_LOG" 2>&1; then
+ BUILD_OK="no"
+ fi
+else
+ : > "$BUILD_LOG"
+fi
+
+{
+ printf 'MCC_SERVERS=%s\n' "$MCC_SERVERS"
+ printf 'RUN_DIR=%s\n' "$MATRIX_DIR"
+ printf 'DATE=%s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
+ printf 'dotnet=%s\n' "$DOTNET_OK"
+ printf 'java=%s\n' "$JAVA_OK"
+ printf 'tmux=%s\n' "$TMUX_OK"
+ printf 'build=%s\n' "$BUILD_OK"
+} > "$PRECHECK_TXT"
+
+while IFS='|' read -r version profile family; do
+ [[ -z "$version" ]] && continue
+
+ if [[ "$DOTNET_OK" != "yes" ]]; then
+ write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
+ "dotnet is not available on PATH."
+ continue
+ fi
+
+ if [[ "$BUILD_OK" != "yes" ]]; then
+ write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
+ "dotnet build failed. See $BUILD_LOG."
+ continue
+ fi
+
+ if [[ "$JAVA_OK" != "yes" || "$TMUX_OK" != "yes" ]]; then
+ write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "❌ Fail" \
+ "java or tmux is not available, so live server execution was blocked."
+ continue
+ fi
+
+ if ! server_dir="$(resolve_server_dir "$version")"; then
+ write_row "$version" "" "" "$family" "❌" "❌" "❌" "❌" "⚠️ Partial" \
+ "Server directory for $version was not found under $MCC_SERVERS."
+ continue
+ fi
+
+ run_version "$version" "$profile" "$family" "$server_dir"
+done <<'EOF'
+1.8|legacy|Legacy 🧱
+1.11.2|legacy|Legacy 🧱
+1.12.2|modern|First advancements 🌱
+1.19.4|modern|Stable modern ✅
+1.20|modern|Telemetry edge 1 ⚠️
+1.20.2|modern|Telemetry edge 2 ⚠️
+1.20.4|modern|End of 1.20.x ⚠️
+1.20.6|modern|Post-1.20.6 🔧
+1.21.2|modern|1.21.2 family 🔧
+1.21.11|modern|showAdvancements 🆕
+26.1|modern|Latest supported 🚀
+EOF
+
+bash "$SCRIPT_DIR/summarize_achievements_matrix.sh" "$MATRIX_DIR" > "$REPORT_MD"
+printf '%s\n' "$MATRIX_DIR"
diff --git a/.skills/mcc-integration-testing/scripts/run_achievements_test.sh b/.skills/mcc-integration-testing/scripts/run_achievements_test.sh
new file mode 100755
index 00000000..88c2b027
--- /dev/null
+++ b/.skills/mcc-integration-testing/scripts/run_achievements_test.sh
@@ -0,0 +1,443 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+# shellcheck source=tools/mcc-env.sh
+source "$REPO_ROOT/tools/mcc-env.sh"
+
+sed_in_place() {
+ if [[ "$(uname)" == "Darwin" ]]; then
+ sed -i '' "$@"
+ else
+ sed -i "$@"
+ fi
+}
+
+usage() {
+ cat <<'EOF'
+Usage: run_achievements_test.sh [--no-build]
+
+Examples:
+ .skills/mcc-integration-testing/scripts/run_achievements_test.sh --no-build 1.8 1.8 legacy
+ .skills/mcc-integration-testing/scripts/run_achievements_test.sh --no-build 1.21.11-Vanilla 1.21.11 modern
+EOF
+}
+
+DO_BUILD=true
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --no-build) DO_BUILD=false; shift ;;
+ --build) DO_BUILD=true; shift ;;
+ -h|--help) usage; exit 0 ;;
+ *) break ;;
+ esac
+done
+
+if [[ $# -ne 3 ]]; then
+ usage >&2
+ exit 1
+fi
+
+SERVER_DIR="$1"
+MC_VERSION="$2"
+PROFILE="$3"
+
+if [[ "$PROFILE" != "legacy" && "$PROFILE" != "modern" ]]; then
+ echo "Unsupported profile: $PROFILE" >&2
+ exit 1
+fi
+
+RUN_ROOT="${TMPDIR:-/tmp}/mcc-achievements"
+RUN_ID="$(date +%Y%m%d-%H%M%S)"
+RUN_DIR="$RUN_ROOT/$SERVER_DIR/$RUN_ID"
+LATEST_LINK="$RUN_ROOT/$SERVER_DIR/latest"
+MCC_LOG="$RUN_DIR/mcc.log"
+BUILD_LOG="$RUN_DIR/build.log"
+SERVER_TMUX_LOG="$RUN_DIR/server-tmux.log"
+SERVER_FILE_LOG="$RUN_DIR/server-latest.log"
+COMMAND_LOG="$RUN_DIR/commands.log"
+SUMMARY_ENV="$RUN_DIR/summary.env"
+PROBE_SCRIPT="$RUN_DIR/achievement_probe.cs"
+CFG="$RUN_DIR/MinecraftClient.$MC_VERSION.ini"
+INPUT_FILE="$REPO_ROOT/mcc_input.txt"
+SERVER_LOG_FILE="$MCC_SERVERS/$SERVER_DIR/logs/latest.log"
+TARGET_ID="minecraft:story/root"
+TARGET_COMMAND_GRANT="advancement grant CursorBot only minecraft:story/root"
+TARGET_COMMAND_REVOKE="advancement revoke CursorBot only minecraft:story/root"
+TARGET_TYPE="Modern 🌱"
+PORT="unknown"
+MCC_PID=""
+
+INITIAL_STATUS="❌"
+GRANT_STATUS="❌"
+REVOKE_STATUS="❌"
+API_STATUS="❌"
+VERDICT="❌ Fail"
+NOTE="Run did not complete."
+EXECUTED="yes"
+
+if [[ "$PROFILE" == "legacy" ]]; then
+ TARGET_ID="achievement.openInventory"
+ TARGET_COMMAND_GRANT="achievement give achievement.openInventory CursorBot"
+ TARGET_COMMAND_REVOKE="achievement take achievement.openInventory CursorBot"
+ TARGET_TYPE="Legacy 🧱"
+fi
+
+mkdir -p "$RUN_DIR"
+
+write_summary() {
+ {
+ printf 'VERSION=%q\n' "$MC_VERSION"
+ printf 'SERVER_DIR=%q\n' "$SERVER_DIR"
+ printf 'PROFILE=%q\n' "$PROFILE"
+ printf 'FAMILY=%q\n' "$TARGET_TYPE"
+ printf 'PORT=%q\n' "$PORT"
+ printf 'RUN_DIR=%q\n' "$RUN_DIR"
+ printf 'MCC_LOG=%q\n' "$MCC_LOG"
+ printf 'SERVER_LOG=%q\n' "$RUN_DIR/server-latest.log"
+ printf 'SERVER_FILE_LOG=%q\n' "$SERVER_LOG_FILE"
+ printf 'SERVER_TMUX_LOG=%q\n' "$SERVER_TMUX_LOG"
+ printf 'COPIED_SERVER_LOG=%q\n' "$RUN_DIR/server-latest.log"
+ printf 'COMMAND_LOG=%q\n' "$COMMAND_LOG"
+ printf 'SUMMARY_ENV=%q\n' "$SUMMARY_ENV"
+ printf 'TARGET_ID=%q\n' "$TARGET_ID"
+ printf 'INITIAL_STATUS=%q\n' "$INITIAL_STATUS"
+ printf 'GRANT_STATUS=%q\n' "$GRANT_STATUS"
+ printf 'REVOKE_STATUS=%q\n' "$REVOKE_STATUS"
+ printf 'API_STATUS=%q\n' "$API_STATUS"
+ printf 'VERDICT=%q\n' "$VERDICT"
+ printf 'NOTE=%q\n' "$NOTE"
+ printf 'EXECUTED=%q\n' "$EXECUTED"
+ } > "$SUMMARY_ENV"
+}
+
+capture_server_logs() {
+ mc-log "$SERVER_DIR" 400 > "$SERVER_TMUX_LOG" 2>/dev/null || true
+ if [[ -f "$SERVER_LOG_FILE" ]]; then
+ cp "$SERVER_LOG_FILE" "$RUN_DIR/server-latest.log" 2>/dev/null || true
+ fi
+}
+
+cleanup() {
+ capture_server_logs
+
+ if [[ -n "${MCC_PID:-}" ]] && kill -0 "$MCC_PID" 2>/dev/null; then
+ echo "quit" >> "$INPUT_FILE" 2>/dev/null || true
+ sleep 2
+ kill "$MCC_PID" 2>/dev/null || true
+ wait "$MCC_PID" 2>/dev/null || true
+ fi
+
+ mc-stop "$SERVER_DIR" >/dev/null 2>&1 || true
+ ln -sfn "$RUN_DIR" "$LATEST_LINK"
+ write_summary
+}
+trap cleanup EXIT
+
+log_step() {
+ printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$1" | tee -a "$COMMAND_LOG"
+}
+
+fail() {
+ NOTE="$1"
+ VERDICT="❌ Fail"
+ exit 1
+}
+
+wait_for_file_pattern() {
+ local file="$1"
+ local pattern="$2"
+ local description="$3"
+ local timeout="${4:-60}"
+ local elapsed=0
+
+ while (( elapsed < timeout )); do
+ if [[ -f "$file" ]] && grep -Fq "$pattern" "$file"; then
+ return 0
+ fi
+ sleep 1
+ ((elapsed += 1))
+ done
+
+ echo "Timed out waiting for: $description" >&2
+ return 1
+}
+
+wait_for_server_ready() {
+ local timeout="${1:-60}"
+ local elapsed=0
+
+ while (( elapsed < timeout )); do
+ if mc-log "$SERVER_DIR" 250 2>/dev/null | grep -Fq "Done ("; then
+ return 0
+ fi
+ sleep 1
+ ((elapsed += 1))
+ done
+
+ echo "Timed out waiting for server readiness" >&2
+ return 1
+}
+
+disable_noisy_bots() {
+ sed_in_place '/^\[ChatBot.ScriptScheduler\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+ sed_in_place '/^\[ChatBot.DiscordRpc\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+ sed_in_place '/^\[ChatBot.AntiAFK\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+ sed_in_place '/^\[ChatBot.AutoDig\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+ sed_in_place '/^\[ChatBot.AutoAttack\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+ sed_in_place '/^\[ChatBot.PlayerListLogger\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+ sed_in_place '/^\[ChatBot.ReplayCapture\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$REPO_ROOT/MinecraftClient.ini"
+}
+
+ensure_root_config() {
+ if [[ -f "$REPO_ROOT/MinecraftClient.ini" ]]; then
+ return
+ fi
+
+ (
+ cd "$REPO_ROOT"
+ dotnet run --project MinecraftClient -c Release --no-build -- --help >/dev/null 2>&1
+ )
+}
+
+write_probe_script() {
+ cat > "$PROBE_SCRIPT" < updated, IReadOnlyList removedIds, bool reset)
+ {
+ LogToConsole($"[ACH_TEST] event reset={reset} updated={updated.Count} removed={removedIds.Count}");
+ DumpState("event");
+ }
+
+ private void DumpState(string origin)
+ {
+ Achievement[] all = GetAchievements();
+ Achievement[] unlocked = GetUnlockedAchievements();
+ Achievement[] locked = GetLockedAchievements();
+ Achievement? target = null;
+
+ foreach (Achievement entry in all)
+ {
+ if (entry.Id == TargetId)
+ {
+ target = entry;
+ break;
+ }
+ }
+
+ string titleState = "missing";
+ string completionState = "missing";
+
+ if (target is not null)
+ {
+ titleState = target.Title is null ? "null" : "present";
+ completionState = target.IsCompleted ? "done" : "todo";
+ }
+
+ LogToConsole($"[ACH_TEST] snapshot origin={origin} all={all.Length} unlocked={unlocked.Length} locked={locked.Length}");
+ LogToConsole($"[ACH_TEST] target_state origin={origin} id={TargetId} title={titleState} completed={completionState}");
+ }
+}
+EOF
+}
+
+run_server_command() {
+ local cmd="$1"
+ local attempt
+
+ log_step "SERVER> $cmd"
+ for attempt in 1 2 3 4 5; do
+ if mc-rcon "$cmd" >/dev/null 2>&1; then
+ sleep 1
+ return 0
+ fi
+ sleep 1
+ done
+
+ fail "Server command failed: $cmd"
+}
+
+run_mcc_command() {
+ local name="$1"
+ local cmd="$2"
+ local delay="${3:-2}"
+ local start_line=0
+ local end_line=0
+
+ if [[ -f "$MCC_LOG" ]]; then
+ start_line="$(wc -l < "$MCC_LOG")"
+ fi
+
+ log_step "MCC> $cmd"
+ echo "$cmd" >> "$INPUT_FILE"
+ sleep "$delay"
+
+ if [[ -f "$MCC_LOG" ]]; then
+ end_line="$(wc -l < "$MCC_LOG")"
+ fi
+
+ if (( end_line > start_line )); then
+ sed -n "$((start_line + 1)),$((end_line))p" "$MCC_LOG" > "$RUN_DIR/$name.mcc.log"
+ else
+ : > "$RUN_DIR/$name.mcc.log"
+ fi
+}
+
+assert_pattern() {
+ local file="$1"
+ local pattern="$2"
+ local description="$3"
+
+ grep -Fq "$pattern" "$file" || fail "$description"
+}
+
+if ! command -v java >/dev/null 2>&1 || ! java -version >/dev/null 2>&1; then
+ fail "java was not found on PATH."
+fi
+
+if ! command -v tmux >/dev/null 2>&1; then
+ fail "tmux was not found on PATH."
+fi
+
+if [[ ! -d "$MCC_SERVERS/$SERVER_DIR" ]]; then
+ fail "Server directory not found: $MCC_SERVERS/$SERVER_DIR"
+fi
+
+PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$SERVER_DIR")"
+
+ensure_root_config
+"$SCRIPT_DIR/ensure_offline_server.sh" "$SERVER_DIR"
+disable_noisy_bots
+write_probe_script
+
+if [[ "$PROFILE" == "legacy" && -f "$MCC_SERVERS/$SERVER_DIR/server.properties" ]]; then
+ sed_in_place 's/^use-native-transport=.*/use-native-transport=false/' "$MCC_SERVERS/$SERVER_DIR/server.properties"
+fi
+
+if $DO_BUILD; then
+ log_step "BUILD> dotnet build MinecraftClient.sln -c Release"
+ mcc-build > "$BUILD_LOG" 2>&1 || fail "dotnet build failed."
+else
+ : > "$BUILD_LOG"
+fi
+
+: > "$INPUT_FILE"
+rm -f "$MCC_LOG"
+
+log_step "Starting server $SERVER_DIR on port $PORT"
+mc-start "$SERVER_DIR" >/dev/null
+wait_for_server_ready || fail "Server did not become ready."
+
+log_step "Starting MCC for $MC_VERSION"
+(
+ cd "$REPO_ROOT"
+ MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- \
+ CursorBot \
+ - \
+ "localhost:$PORT" \
+ "--accounttype=mojang" \
+ "--minecraftversion=$MC_VERSION" \
+ "--terrainandmovements=true" \
+ "--inventoryhandling=true" \
+ "--entityhandling=true" \
+ "--autorespawn=true" \
+ "--debugmessages=true" \
+ > "$MCC_LOG" 2>&1
+) &
+MCC_PID=$!
+
+wait_for_file_pattern "$MCC_LOG" "Server was successfully joined." "MCC join success" 90 || fail "MCC failed to join."
+wait_for_file_pattern "$SERVER_LOG_FILE" "CursorBot joined the game" "server join entry" 30 || fail "Server never logged the join."
+
+run_server_command "op CursorBot"
+run_server_command "gamerule sendCommandFeedback true"
+if [[ "$PROFILE" == "modern" ]]; then
+ run_server_command "gamerule logAdminCommands true"
+fi
+run_server_command "time set day"
+run_server_command "weather clear"
+
+run_mcc_command "load_probe" "script $PROBE_SCRIPT" 3
+wait_for_file_pattern "$MCC_LOG" "[ACH_TEST] probe initialized" "probe startup" 30 || fail "Probe script did not initialize."
+
+run_mcc_command "baseline_debug" "debug state" 2
+run_mcc_command "baseline_all" "achievement" 2
+run_mcc_command "baseline_locked" "achievement locked" 2
+run_mcc_command "baseline_unlocked" "achievement unlocked" 2
+
+run_server_command "$TARGET_COMMAND_GRANT"
+sleep 3
+run_mcc_command "after_grant_all" "achievement" 2
+run_mcc_command "after_grant_unlocked" "achievement unlocked" 2
+
+run_server_command "$TARGET_COMMAND_REVOKE"
+sleep 3
+run_mcc_command "after_revoke_all" "achievement" 2
+run_mcc_command "after_revoke_locked" "achievement locked" 2
+
+assert_pattern "$MCC_LOG" "Achievements/Advancements:" "Achievement command header never appeared."
+
+if ! grep -Fq "No achievements/advancements received yet." "$RUN_DIR/baseline_all.mcc.log"; then
+ INITIAL_STATUS="✅"
+fi
+
+if grep -Fq "$TARGET_ID" "$RUN_DIR/after_grant_unlocked.mcc.log" && grep -Fq "[DONE]" "$RUN_DIR/after_grant_unlocked.mcc.log"; then
+ GRANT_STATUS="✅"
+fi
+
+if [[ "$PROFILE" == "legacy" ]]; then
+ if grep -Fq "$TARGET_ID" "$RUN_DIR/after_revoke_locked.mcc.log" && grep -Fq "[TODO]" "$RUN_DIR/after_revoke_locked.mcc.log"; then
+ REVOKE_STATUS="✅"
+ fi
+else
+ if grep -Fq "$TARGET_ID" "$RUN_DIR/after_revoke_locked.mcc.log" && grep -Fq "[TODO]" "$RUN_DIR/after_revoke_locked.mcc.log"; then
+ REVOKE_STATUS="✅"
+ elif [[ "$GRANT_STATUS" == "✅" ]] && ! grep -Fq "$TARGET_ID" "$RUN_DIR/after_revoke_all.mcc.log"; then
+ REVOKE_STATUS="✅"
+ fi
+fi
+
+if grep -Fq "[ACH_TEST] event" "$MCC_LOG" && grep -Fq "target_state origin=event id=$TARGET_ID title=" "$MCC_LOG"; then
+ API_STATUS="✅"
+fi
+
+case "$INITIAL_STATUS|$GRANT_STATUS|$REVOKE_STATUS|$API_STATUS" in
+ "✅|✅|✅|✅")
+ VERDICT="✅ Pass"
+ NOTE="All planned achievement checks passed."
+ ;;
+ *"✅"*)
+ VERDICT="⚠️ Partial"
+ NOTE="At least one achievement phase passed, but the matrix did not fully clear."
+ ;;
+ *)
+ VERDICT="❌ Fail"
+ NOTE="Achievement checks did not produce the expected evidence."
+ ;;
+esac
+
+run_mcc_command "quit" "quit" 2
+NOTE="$NOTE Artifacts saved in $RUN_DIR."
diff --git a/.skills/mcc-integration-testing/scripts/summarize_achievements_matrix.sh b/.skills/mcc-integration-testing/scripts/summarize_achievements_matrix.sh
new file mode 100755
index 00000000..9dbeb78c
--- /dev/null
+++ b/.skills/mcc-integration-testing/scripts/summarize_achievements_matrix.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "Usage: summarize_achievements_matrix.sh " >&2
+ exit 1
+fi
+
+MATRIX_DIR="$1"
+RESULTS_TSV="$MATRIX_DIR/results.tsv"
+PRECHECK_TXT="$MATRIX_DIR/preflight.txt"
+BUILD_LOG="$MATRIX_DIR/build.log"
+
+if [[ ! -f "$RESULTS_TSV" ]]; then
+ echo "Missing results file: $RESULTS_TSV" >&2
+ exit 1
+fi
+
+echo "# Achievements Matrix Report"
+echo
+echo "## Executed"
+echo
+if [[ -f "$PRECHECK_TXT" ]]; then
+ echo '```text'
+ cat "$PRECHECK_TXT"
+ echo '```'
+fi
+echo
+echo "- Matrix artifacts: \`$MATRIX_DIR\`"
+echo "- Results TSV: \`$RESULTS_TSV\`"
+echo "- Build log: \`$BUILD_LOG\`"
+echo "- Execution mode: sequential"
+echo "- Auth mode: offline"
+echo
+echo "## Observed"
+echo
+echo "| Version | Port | Family | Initial snapshot | Grant | Revoke | API callback | Verdict |"
+echo "|---|---:|---|---|---|---|---|---|"
+awk -F '\t' 'NR > 1 {
+ printf("| `%s` | `%s` | %s | %s | %s | %s | %s | %s |\n",
+ $1, $3, $4, $5, $6, $7, $8, $9);
+}' "$RESULTS_TSV"
+
+echo
+echo "## Artifact Links"
+echo
+awk -F '\t' 'NR > 1 {
+ printf("- `%s`: run=`%s`, mcc=`%s`, server=`%s`, commands=`%s`\n", $1, $11, $12, $13, $14);
+ printf(" note: %s\n", $10);
+}' "$RESULTS_TSV"
+
+echo
+echo "## Inferred"
+echo
+echo "- Only rows with real MCC and server-log artifacts count as executed proof."
+echo "- Rows blocked by missing Java, tmux, or server directories are environment-limited, not product pass results."
+echo "- Legacy rows remain the highest-risk bucket because static inspection suggests pre-1.12 \`Statistics\` packets may not currently reach the achievements handler."
diff --git a/MinecraftClient/LegacyAchievementCatalog.cs b/MinecraftClient/LegacyAchievementCatalog.cs
new file mode 100644
index 00000000..bce17f8d
--- /dev/null
+++ b/MinecraftClient/LegacyAchievementCatalog.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+
+namespace MinecraftClient
+{
+ internal static class LegacyAchievementCatalog
+ {
+ public static IReadOnlyList Ids { get; } =
+ [
+ "achievement.openInventory",
+ "achievement.mineWood",
+ "achievement.buildWorkBench",
+ "achievement.buildPickaxe",
+ "achievement.buildFurnace",
+ "achievement.acquireIron",
+ "achievement.buildHoe",
+ "achievement.makeBread",
+ "achievement.bakeCake",
+ "achievement.buildBetterPickaxe",
+ "achievement.cookFish",
+ "achievement.onARail",
+ "achievement.buildSword",
+ "achievement.killEnemy",
+ "achievement.killCow",
+ "achievement.flyPig",
+ "achievement.snipeSkeleton",
+ "achievement.diamonds",
+ "achievement.diamondsToYou",
+ "achievement.portal",
+ "achievement.ghast",
+ "achievement.blazeRod",
+ "achievement.potion",
+ "achievement.theEnd",
+ "achievement.theEnd2",
+ "achievement.enchantments",
+ "achievement.overkill",
+ "achievement.bookcase",
+ "achievement.breedCow",
+ "achievement.spawnWither",
+ "achievement.killWither",
+ "achievement.fullBeacon",
+ "achievement.exploreAllBiomes",
+ "achievement.overpowered"
+ ];
+
+ private static readonly HashSet s_idSet = new(Ids, StringComparer.Ordinal);
+
+ public static bool Contains(string id)
+ {
+ return s_idSet.Contains(id);
+ }
+ }
+}
diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs
index 7032bcbd..eeabc8e0 100644
--- a/MinecraftClient/Protocol/Handlers/Protocol18.cs
+++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs
@@ -91,6 +91,7 @@ namespace MinecraftClient.Protocol.Handlers
private int currentDimension;
private bool isOnlineMode = false;
private readonly BlockingCollection>> packetQueue = new();
+ private readonly Dictionary legacyAchievementProgress = new(StringComparer.Ordinal);
private float LastYaw, LastPitch;
private double lastSentX, lastSentY, lastSentZ;
private float lastSentYaw, lastSentPitch;
@@ -120,6 +121,7 @@ namespace MinecraftClient.Protocol.Handlers
Tuple? netReader = null; // reader thread
readonly ILogger log;
readonly RandomNumberGenerator randomGen;
+ private bool legacyAchievementsInitialized;
public Protocol18Handler(TcpClient Client, int protocolVersion, IMinecraftComHandler handler,
ForgeInfo? forgeInfo, int rawProtocolVersion = 0)
@@ -3132,6 +3134,11 @@ namespace MinecraftClient.Protocol.Handlers
case PacketTypesIn.RecipeBookSettings:
break;
+ case PacketTypesIn.Statistics:
+ if (protocolVersion < MC_1_12_Version)
+ HandleLegacyStatistics(packetData);
+ break;
+
case PacketTypesIn.Advancements:
HandleAdvancements(packetData);
break;
@@ -3147,9 +3154,39 @@ namespace MinecraftClient.Protocol.Handlers
return true; //Packet processed
}
+ ///
+ /// Handle the Statistics packet for pre-1.12 legacy achievements.
+ ///
+ private void HandleLegacyStatistics(Queue packetData)
+ {
+ int statCount = dataTypes.ReadNextVarInt(packetData);
+
+ for (int i = 0; i < statCount; i++)
+ {
+ string statId = dataTypes.ReadNextString(packetData);
+ int value = dataTypes.ReadNextVarInt(packetData);
+
+ if (statId.StartsWith("achievement.", StringComparison.Ordinal))
+ legacyAchievementProgress[statId] = value > 0;
+ }
+
+ List added = new(LegacyAchievementCatalog.Ids.Count + legacyAchievementProgress.Count);
+
+ foreach (string achievementId in LegacyAchievementCatalog.Ids)
+ added.Add(CreateLegacyAchievement(achievementId, legacyAchievementProgress.TryGetValue(achievementId, out bool completed) && completed));
+
+ foreach (var (achievementId, completed) in legacyAchievementProgress)
+ {
+ if (!LegacyAchievementCatalog.Contains(achievementId))
+ added.Add(CreateLegacyAchievement(achievementId, completed));
+ }
+
+ handler.OnAchievementsUpdate(added, [], reset: !legacyAchievementsInitialized);
+ legacyAchievementsInitialized = true;
+ }
+
///
/// Handle the Advancements packet (1.12+).
- /// Also handles the Statistics packet for pre-1.12 legacy achievements.
///
private void HandleAdvancements(Queue packetData)
{
@@ -3282,6 +3319,16 @@ namespace MinecraftClient.Protocol.Handlers
handler.OnAchievementsUpdate(added, removedIds, reset);
}
+ private static Achievement CreateLegacyAchievement(string id, bool isCompleted)
+ {
+ Dictionary criteria = new(StringComparer.Ordinal)
+ {
+ [id] = isCompleted
+ };
+ IReadOnlyList[] requirements = [[id]];
+ return new Achievement(id, null, null, AchievementType.Legacy, false, isCompleted, requirements, criteria);
+ }
+
///
/// Compute whether an advancement is completed based on AND-of-ORs requirements.
///
From 62740ee94e50e1a7eb5e2bfc762e0667e8ce00f8 Mon Sep 17 00:00:00 2001
From: milutinke
Date: Mon, 30 Mar 2026 17:28:15 +0200
Subject: [PATCH 9/9] Document achievements feature
---
docs/guide/creating-bots.md | 52 +++++++++++++++++++++++++++++++++++++
docs/guide/usage.md | 48 ++++++++++++++++++++++++++++++++++
2 files changed, 100 insertions(+)
diff --git a/docs/guide/creating-bots.md b/docs/guide/creating-bots.md
index e374f273..ed35d549 100644
--- a/docs/guide/creating-bots.md
+++ b/docs/guide/creating-bots.md
@@ -229,6 +229,58 @@ Make a built-in MCC chat bot named AutoTorch and wire it fully into the repo con
Create a standalone MCC /script bot that follows private messages, uses GetVerbatim(text), and replies only to bot owners. Use the mcc-chatbot-authoring skill.
```
+## Achievements And Advancements
+
+Chat bots and C# scripts can read the current achievement state and react to updates.
+
+Useful methods:
+
+- `GetAchievements()`
+- `GetUnlockedAchievements()`
+- `GetLockedAchievements()`
+- `OnAchievementUpdate(IReadOnlyList updated, IReadOnlyList removedIds, bool reset)`
+
+Things worth knowing:
+
+- On `1.8` to `1.11.2`, ids use the legacy `achievement.*` format.
+- On `1.12+`, ids use advancement resource ids such as `minecraft:story/root`.
+- Legacy achievements usually have `Title = null` and `Description = null` because the server does not send display metadata in the statistics packet.
+- On newer versions, revoking an advancement may remove it from the current set instead of turning it into a locked entry, so `removedIds` matters.
+
+Example:
+
+```csharp
+//MCCScript 1.0
+
+MCC.LoadBot(new AchievementWatcher());
+
+//MCCScript Extensions
+
+public class AchievementWatcher : ChatBot
+{
+ public override void AfterGameJoined()
+ {
+ Achievement[] known = GetAchievements();
+ LogToConsole($"Known achievements: {known.Length}");
+ }
+
+ public override void OnAchievementUpdate(IReadOnlyList updated, IReadOnlyList removedIds, bool reset)
+ {
+ LogToConsole($"Achievement update: reset={reset}, updated={updated.Count}, removed={removedIds.Count}");
+
+ foreach (Achievement achievement in updated)
+ {
+ string title = achievement.Title ?? achievement.Id;
+ string state = achievement.IsCompleted ? "done" : "todo";
+ LogToConsole($" - {title}: {state}");
+ }
+
+ foreach (string removedId in removedIds)
+ LogToConsole($" - removed: {removedId}");
+ }
+}
+```
+
## C# API
The authoritative reference for the C# API is [ChatBot.cs](https://github.com/MCCTeam/Minecraft-Console-Client/blob/master/MinecraftClient/Scripting/ChatBot.cs).
diff --git a/docs/guide/usage.md b/docs/guide/usage.md
index 0a439aff..e0e4a0ea 100644
--- a/docs/guide/usage.md
+++ b/docs/guide/usage.md
@@ -219,6 +219,54 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
+
+achievement
+
+- **Description:**
+
+ Show the achievements or advancements currently known to MCC.
+
+ On Minecraft `1.8` to `1.11.2`, MCC tracks legacy achievements such as `achievement.openInventory`.
+
+ On Minecraft `1.12+`, MCC tracks advancements such as `minecraft:story/root`.
+
+- **Usage:**
+
+ ```
+ /achievement
+ /achievement list
+ /achievement locked
+ /achievement unlocked
+ ```
+
+- **Examples:**
+
+ List everything MCC currently knows:
+
+ ```
+ /achievement
+ ```
+
+ Show only incomplete entries:
+
+ ```
+ /achievement locked
+ ```
+
+ Show only completed entries:
+
+ ```
+ /achievement unlocked
+ ```
+
+- **Notes:**
+
+ The command only shows data the server has already sent to MCC.
+
+ Legacy achievements do not include titles or descriptions in the protocol, so older servers usually show the raw id instead.
+
+
+
bed