feat: Added achievements/advancements support

feat: Add achievements/advancements support
This commit is contained in:
Anon 2026-03-30 17:31:47 +02:00 committed by GitHub
commit 6b5435629f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1539 additions and 2 deletions

1
.gitignore vendored
View file

@ -437,3 +437,4 @@ FodyWeavers.xsd
/.specstory/ /.specstory/
/.vscode/settings.json /.vscode/settings.json
/Sentry/ /Sentry/
server.pid

View file

@ -6,6 +6,14 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=tools/mcc-env.sh # shellcheck source=tools/mcc-env.sh
source "$REPO_ROOT/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}" VERSION="${1:-1.21.11-Vanilla}"
SERVER_DIR="${MCC_SERVERS:?}/$VERSION" SERVER_DIR="${MCC_SERVERS:?}/$VERSION"
PROPS_FILE="$SERVER_DIR/server.properties" PROPS_FILE="$SERVER_DIR/server.properties"
@ -49,6 +57,15 @@ wait_for_server_stop() {
sleep 1 sleep 1
((elapsed += 1)) ((elapsed += 1))
done 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 echo "Timed out waiting for $VERSION to stop" >&2
return 1 return 1
} }
@ -58,7 +75,7 @@ upsert_property() {
local value="$2" local value="$2"
if grep -Eq "^${key}=" "$PROPS_FILE"; then 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 else
printf '%s=%s\n' "$key" "$value" >> "$PROPS_FILE" printf '%s=%s\n' "$key" "$value" >> "$PROPS_FILE"
fi fi

View file

@ -1,6 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
sed_in_place() {
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
if [[ $# -lt 3 || $# -gt 4 ]]; then if [[ $# -lt 3 || $# -gt 4 ]]; then
echo "Usage: $0 <template-ini> <output-ini> <mc-version> [login]" >&2 echo "Usage: $0 <template-ini> <output-ini> <mc-version> [login]" >&2
exit 1 exit 1
@ -28,7 +36,7 @@ fi
cp "$TEMPLATE_INI" "$OUTPUT_INI" cp "$TEMPLATE_INI" "$OUTPUT_INI"
sed -i \ sed_in_place \
-e "s#^Account = .*#Account = { Login = \"$LOGIN_NAME\", Password = \"$PASSWORD_VALUE\" }#" \ -e "s#^Account = .*#Account = { Login = \"$LOGIN_NAME\", Password = \"$PASSWORD_VALUE\" }#" \
-e "s#^AccountType = .*#AccountType = \"$ACCOUNT_TYPE\"#" \ -e "s#^AccountType = .*#AccountType = \"$ACCOUNT_TYPE\"#" \
-e "s#^MinecraftVersion = \"[^\"]*\"\\(.*\\)\$#MinecraftVersion = \"$MC_VERSION\"\\1#" \ -e "s#^MinecraftVersion = \"[^\"]*\"\\(.*\\)\$#MinecraftVersion = \"$MC_VERSION\"\\1#" \

View file

@ -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"

View file

@ -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] <server-dir> <mc-version> <legacy|modern>
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" <<EOF
//MCCScript 1.0
MCC.LoadBot(new AchievementProbeBot());
//MCCScript Extensions
public class AchievementProbeBot : ChatBot
{
private const string TargetId = "$TARGET_ID";
public override void Initialize()
{
LogToConsole("[ACH_TEST] probe initialized");
DumpState("initialize");
}
public override void AfterGameJoined()
{
LogToConsole("[ACH_TEST] after join");
DumpState("after_join");
}
public override void OnAchievementUpdate(IReadOnlyList<Achievement> updated, IReadOnlyList<string> 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."

View file

@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "Usage: summarize_achievements_matrix.sh <matrix-run-dir>" >&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."

View file

@ -0,0 +1,36 @@
using System.Collections.Generic;
namespace MinecraftClient
{
/// <summary>
/// The type of an achievement or advancement.
/// </summary>
public enum AchievementType
{
Task,
Challenge,
Goal,
Legacy
}
/// <summary>
/// Represents a Minecraft achievement (pre-1.12) or advancement (1.12+).
/// </summary>
/// <param name="Id">Resource identifier, e.g. "minecraft:story/root" or "achievement.openInventory"</param>
/// <param name="Title">Display title (null for legacy achievements without display info)</param>
/// <param name="Description">Display description (null for legacy achievements without display info)</param>
/// <param name="Type">The frame type / achievement category</param>
/// <param name="IsHidden">Whether this advancement is hidden in the UI</param>
/// <param name="IsCompleted">Whether all requirements have been met</param>
/// <param name="Requirements">OR-groups of criterion names; all groups must be satisfied</param>
/// <param name="CriteriaProgress">Per-criterion completion status</param>
public record Achievement(
string Id,
string? Title,
string? Description,
AchievementType Type,
bool IsHidden,
bool IsCompleted,
IReadOnlyList<IReadOnlyList<string>> Requirements,
IReadOnlyDictionary<string, bool> CriteriaProgress);
}

View file

@ -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 <list|locked|unlocked>";
public override string CmdDesc => Translations.cmd_achievement_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> 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
});
}
/// <param name="completed">null = all, true = unlocked only, false = locked only</param>
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);
}
}
}

View file

@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
namespace MinecraftClient
{
internal static class LegacyAchievementCatalog
{
public static IReadOnlyList<string> 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<string> s_idSet = new(Ids, StringComparer.Ordinal);
public static bool Contains(string id)
{
return s_idSet.Contains(id);
}
}
}

View file

@ -45,11 +45,14 @@ namespace MinecraftClient
private readonly Queue<Action> threadTasks = new(); private readonly Queue<Action> threadTasks = new();
private readonly Lock threadTasksLock = new(); private readonly Lock threadTasksLock = new();
private readonly Lock recipeBookLock = new(); private readonly Lock recipeBookLock = new();
private readonly Lock achievementsLock = new();
private readonly List<ChatBot> bots = new(); private readonly List<ChatBot> bots = new();
private static readonly List<ChatBot> botsOnHold = new(); private static readonly List<ChatBot> botsOnHold = new();
private static readonly Dictionary<int, Container> inventories = new(); private static readonly Dictionary<int, Container> inventories = new();
private readonly Dictionary<string, RecipeBookRecipeEntry> unlockedRecipes = new(StringComparer.Ordinal); private readonly Dictionary<string, RecipeBookRecipeEntry> unlockedRecipes = new(StringComparer.Ordinal);
private readonly Dictionary<string, Achievement> achievements = new(StringComparer.Ordinal);
private string? activeAdvancementTab;
private readonly Dictionary<string, List<ChatBot>> registeredBotPluginChannels = new(); private readonly Dictionary<string, List<ChatBot>> registeredBotPluginChannels = new();
private readonly List<string> registeredServerPluginChannels = new(); private readonly List<string> registeredServerPluginChannels = new();
@ -1353,6 +1356,42 @@ namespace MinecraftClient
} }
} }
/// <summary>
/// Get all achievements/advancements known to the client.
/// </summary>
/// <returns>Snapshot of all achievements</returns>
public Achievement[] GetAchievements()
{
lock (achievementsLock)
{
return [.. achievements.Values];
}
}
/// <summary>
/// Get only completed achievements/advancements.
/// </summary>
/// <returns>Snapshot of completed achievements</returns>
public Achievement[] GetUnlockedAchievements()
{
lock (achievementsLock)
{
return achievements.Values.Where(static a => a.IsCompleted).ToArray();
}
}
/// <summary>
/// Get only incomplete achievements/advancements.
/// </summary>
/// <returns>Snapshot of locked achievements</returns>
public Achievement[] GetLockedAchievements()
{
lock (achievementsLock)
{
return achievements.Values.Where(static a => !a.IsCompleted).ToArray();
}
}
/// <summary> /// <summary>
/// Get all Entities /// Get all Entities
/// </summary> /// </summary>
@ -4139,6 +4178,67 @@ namespace MinecraftClient
} }
} }
public void OnAchievementsUpdate(IReadOnlyList<Achievement> added, IReadOnlyList<string> 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;
}
/// <summary>
/// Compute whether an achievement is completed based on AND-of-ORs requirements.
/// </summary>
private static bool ComputeAchievementCompleted(IReadOnlyList<IReadOnlyList<string>> requirements, IReadOnlyDictionary<string, bool> criteria)
{
if (requirements.Count == 0)
return true;
foreach (IReadOnlyList<string> 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;
}
/// <summary> /// <summary>
/// Send a click container button packet to the server. /// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom /// Used for Enchanting table, Lectern, stone cutter and loom

View file

@ -91,6 +91,7 @@ namespace MinecraftClient.Protocol.Handlers
private int currentDimension; private int currentDimension;
private bool isOnlineMode = false; private bool isOnlineMode = false;
private readonly BlockingCollection<Tuple<int, Queue<byte>>> packetQueue = new(); private readonly BlockingCollection<Tuple<int, Queue<byte>>> packetQueue = new();
private readonly Dictionary<string, bool> legacyAchievementProgress = new(StringComparer.Ordinal);
private float LastYaw, LastPitch; private float LastYaw, LastPitch;
private double lastSentX, lastSentY, lastSentZ; private double lastSentX, lastSentY, lastSentZ;
private float lastSentYaw, lastSentPitch; private float lastSentYaw, lastSentPitch;
@ -120,6 +121,7 @@ namespace MinecraftClient.Protocol.Handlers
Tuple<Thread, CancellationTokenSource>? netReader = null; // reader thread Tuple<Thread, CancellationTokenSource>? netReader = null; // reader thread
readonly ILogger log; readonly ILogger log;
readonly RandomNumberGenerator randomGen; readonly RandomNumberGenerator randomGen;
private bool legacyAchievementsInitialized;
public Protocol18Handler(TcpClient Client, int protocolVersion, IMinecraftComHandler handler, public Protocol18Handler(TcpClient Client, int protocolVersion, IMinecraftComHandler handler,
ForgeInfo? forgeInfo, int rawProtocolVersion = 0) ForgeInfo? forgeInfo, int rawProtocolVersion = 0)
@ -3132,6 +3134,19 @@ namespace MinecraftClient.Protocol.Handlers
case PacketTypesIn.RecipeBookSettings: case PacketTypesIn.RecipeBookSettings:
break; break;
case PacketTypesIn.Statistics:
if (protocolVersion < MC_1_12_Version)
HandleLegacyStatistics(packetData);
break;
case PacketTypesIn.Advancements:
HandleAdvancements(packetData);
break;
case PacketTypesIn.SelectAdvancementTab:
HandleSelectAdvancementTab(packetData);
break;
default: default:
return false; //Ignored packet return false; //Ignored packet
} }
@ -3139,6 +3154,218 @@ namespace MinecraftClient.Protocol.Handlers
return true; //Packet processed return true; //Packet processed
} }
/// <summary>
/// Handle the Statistics packet for pre-1.12 legacy achievements.
/// </summary>
private void HandleLegacyStatistics(Queue<byte> 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<Achievement> 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;
}
/// <summary>
/// Handle the Advancements packet (1.12+).
/// </summary>
private void HandleAdvancements(Queue<byte> packetData)
{
bool reset = dataTypes.ReadNextBool(packetData);
// --- Added advancements ---
int addedCount = dataTypes.ReadNextVarInt(packetData);
var added = new List<Achievement>(addedCount);
var addedDefinitions = new Dictionary<string, (string? title, string? description, AchievementType type, bool isHidden, List<List<string>> 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<List<string>>();
if (protocolVersion < MC_1_20_2_Version)
{
// 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
}
// Requirements (all versions)
int reqGroupCount = dataTypes.ReadNextVarInt(packetData);
for (int g = 0; g < reqGroupCount; g++)
{
int groupSize = dataTypes.ReadNextVarInt(packetData);
var group = new List<string>(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);
}
// --- Removed advancement IDs ---
int removedCount = dataTypes.ReadNextVarInt(packetData);
var removedIds = new List<string>(removedCount);
for (int i = 0; i < removedCount; i++)
removedIds.Add(dataTypes.ReadNextString(packetData));
// --- Progress updates ---
int progressCount = dataTypes.ReadNextVarInt(packetData);
var progressMap = new Dictionary<string, Dictionary<string, bool>>(progressCount);
for (int i = 0; i < progressCount; i++)
{
string id = dataTypes.ReadNextString(packetData);
int criteriaEntries = dataTypes.ReadNextVarInt(packetData);
var criteria = new Dictionary<string, bool>(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<string, bool>();
bool isCompleted = ComputeAdvancementCompleted(def.requirements, criteria);
var readOnlyReqs = def.requirements.ConvertAll<IReadOnlyList<string>>(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)
foreach (var (id, criteria) in progressMap)
{
if (!addedDefinitions.ContainsKey(id))
added.Add(new Achievement(id, null, null, AchievementType.Task, false, false, [], criteria));
}
handler.OnAchievementsUpdate(added, removedIds, reset);
}
private static Achievement CreateLegacyAchievement(string id, bool isCompleted)
{
Dictionary<string, bool> criteria = new(StringComparer.Ordinal)
{
[id] = isCompleted
};
IReadOnlyList<string>[] requirements = [[id]];
return new Achievement(id, null, null, AchievementType.Legacy, false, isCompleted, requirements, criteria);
}
/// <summary>
/// Compute whether an advancement is completed based on AND-of-ORs requirements.
/// </summary>
private static bool ComputeAdvancementCompleted(List<List<string>> requirements, Dictionary<string, bool> 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;
}
/// <summary>
/// Handle the SelectAdvancementTab packet.
/// </summary>
private void HandleSelectAdvancementTab(Queue<byte> packetData)
{
bool hasTab = dataTypes.ReadNextBool(packetData);
string? tabId = hasTab ? dataTypes.ReadNextString(packetData) : null;
handler.OnSelectAdvancementTab(tabId);
}
private void HandleUnlockRecipes(Queue<byte> packetData) private void HandleUnlockRecipes(Queue<byte> packetData)
{ {
int action = dataTypes.ReadNextVarInt(packetData); int action = dataTypes.ReadNextVarInt(packetData);
@ -3290,6 +3517,29 @@ namespace MinecraftClient.Protocol.Handlers
private string ReadSlotDisplayLabel(Queue<byte> packetData) private string ReadSlotDisplayLabel(Queue<byte> packetData)
{ {
int slotDisplayType = dataTypes.ReadNextVarInt(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 return slotDisplayType switch
{ {
0 => "Empty", 0 => "Empty",
@ -3304,6 +3554,34 @@ namespace MinecraftClient.Protocol.Handlers
}; };
} }
/// <summary>
/// Reads a with_any_potion slot display (26.1+): contains a nested SlotDisplay.
/// </summary>
private string ReadWithAnyPotionSlotDisplayLabel(Queue<byte> packetData)
{
return ReadSlotDisplayLabel(packetData);
}
/// <summary>
/// Reads an only_with_component slot display (26.1+): contains a nested SlotDisplay and a DataComponentType VarInt ID.
/// </summary>
private string ReadOnlyWithComponentSlotDisplayLabel(Queue<byte> packetData)
{
string sourceLabel = ReadSlotDisplayLabel(packetData);
_ = dataTypes.ReadNextVarInt(packetData); // DataComponentType registry id
return sourceLabel;
}
/// <summary>
/// Reads a dyed slot display (26.1+): contains two nested SlotDisplays (dye + target).
/// </summary>
private string ReadDyedSlotDisplayLabel(Queue<byte> packetData)
{
_ = ReadSlotDisplayLabel(packetData); // dye
string targetLabel = ReadSlotDisplayLabel(packetData); // target
return targetLabel;
}
private string ReadSmithingTrimSlotDisplayLabel(Queue<byte> packetData) private string ReadSmithingTrimSlotDisplayLabel(Queue<byte> packetData)
{ {
string baseLabel = ReadSlotDisplayLabel(packetData); string baseLabel = ReadSlotDisplayLabel(packetData);

View file

@ -530,6 +530,20 @@ namespace MinecraftClient.Protocol
/// <param name="recipeIds">Recipe identifiers to remove</param> /// <param name="recipeIds">Recipe identifiers to remove</param>
public void OnRecipeBookRemove(string[] recipeIds); public void OnRecipeBookRemove(string[] recipeIds);
/// <summary>
/// Called when achievement/advancement data is received from the server.
/// </summary>
/// <param name="added">Achievements that were added or updated</param>
/// <param name="removedIds">IDs of achievements that were removed</param>
/// <param name="reset">True if all existing state should be cleared before applying</param>
public void OnAchievementsUpdate(IReadOnlyList<Achievement> added, IReadOnlyList<string> removedIds, bool reset);
/// <summary>
/// Called when the server selects an advancement tab.
/// </summary>
/// <param name="tabId">The tab identifier, or null if no tab is selected</param>
public void OnSelectAdvancementTab(string? tabId);
/// <summary> /// <summary>
/// Send a click container button packet to the server. /// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom /// Used for Enchanting table, Lectern, stone cutter and loom

View file

@ -7174,5 +7174,104 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.minimap.position_set", resourceCulture); return ResourceManager.GetString("cmd.minimap.position_set", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to list achievements/advancements from the server..
/// </summary>
internal static string cmd_achievement_desc {
get {
return ResourceManager.GetString("cmd.achievement.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No achievements/advancements received yet..
/// </summary>
internal static string cmd_achievement_none {
get {
return ResourceManager.GetString("cmd.achievement.none", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No completed achievements/advancements..
/// </summary>
internal static string cmd_achievement_none_unlocked {
get {
return ResourceManager.GetString("cmd.achievement.none_unlocked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No incomplete achievements/advancements..
/// </summary>
internal static string cmd_achievement_none_locked {
get {
return ResourceManager.GetString("cmd.achievement.none_locked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Achievements/Advancements:.
/// </summary>
internal static string cmd_achievement_header {
get {
return ResourceManager.GetString("cmd.achievement.header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Completed achievements/advancements:.
/// </summary>
internal static string cmd_achievement_header_unlocked {
get {
return ResourceManager.GetString("cmd.achievement.header_unlocked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Incomplete achievements/advancements:.
/// </summary>
internal static string cmd_achievement_header_locked {
get {
return ResourceManager.GetString("cmd.achievement.header_locked", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to [DONE].
/// </summary>
internal static string cmd_achievement_done {
get {
return ResourceManager.GetString("cmd.achievement.done", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to [TODO].
/// </summary>
internal static string cmd_achievement_todo {
get {
return ResourceManager.GetString("cmd.achievement.todo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} ({2}) [{3}].
/// </summary>
internal static string cmd_achievement_entry_titled {
get {
return ResourceManager.GetString("cmd.achievement.entry_titled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1} [{2}].
/// </summary>
internal static string cmd_achievement_entry {
get {
return ResourceManager.GetString("cmd.achievement.entry", resourceCulture);
}
}
} }
} }

View file

@ -2527,4 +2527,37 @@ see item details.</value>
<data name="cmd.minimap.position_set" xml:space="preserve"> <data name="cmd.minimap.position_set" xml:space="preserve">
<value>Minimap position set to: {0}</value> <value>Minimap position set to: {0}</value>
</data> </data>
<data name="cmd.achievement.desc" xml:space="preserve">
<value>list achievements/advancements from the server.</value>
</data>
<data name="cmd.achievement.none" xml:space="preserve">
<value>No achievements/advancements received yet.</value>
</data>
<data name="cmd.achievement.none_unlocked" xml:space="preserve">
<value>No completed achievements/advancements.</value>
</data>
<data name="cmd.achievement.none_locked" xml:space="preserve">
<value>No incomplete achievements/advancements.</value>
</data>
<data name="cmd.achievement.header" xml:space="preserve">
<value>Achievements/Advancements:</value>
</data>
<data name="cmd.achievement.header_unlocked" xml:space="preserve">
<value>Completed achievements/advancements:</value>
</data>
<data name="cmd.achievement.header_locked" xml:space="preserve">
<value>Incomplete achievements/advancements:</value>
</data>
<data name="cmd.achievement.done" xml:space="preserve">
<value>[DONE]</value>
</data>
<data name="cmd.achievement.todo" xml:space="preserve">
<value>[TODO]</value>
</data>
<data name="cmd.achievement.entry_titled" xml:space="preserve">
<value>{0} {1} ({2}) [{3}]</value>
</data>
<data name="cmd.achievement.entry" xml:space="preserve">
<value>{0} {1} [{2}]</value>
</data>
</root> </root>

View file

@ -514,6 +514,14 @@ namespace MinecraftClient.Scripting
/// <param name="block">The block</param> /// <param name="block">The block</param>
public virtual void OnBlockChange(Location location, Block block) { } public virtual void OnBlockChange(Location location, Block block) { }
/// <summary>
/// Called when achievement/advancement data is updated.
/// </summary>
/// <param name="updated">Achievements that were added or updated</param>
/// <param name="removedIds">IDs of achievements that were removed</param>
/// <param name="reset">Whether the achievement state was fully reset before this update</param>
public virtual void OnAchievementUpdate(IReadOnlyList<Achievement> updated, IReadOnlyList<string> removedIds, bool reset) { }
/* =================================================================== */ /* =================================================================== */
/* ToolBox - Methods below might be useful while creating your bot. */ /* ToolBox - Methods below might be useful while creating your bot. */
/* You should not need to interact with other classes of the program. */ /* You should not need to interact with other classes of the program. */
@ -1120,6 +1128,33 @@ namespace MinecraftClient.Scripting
return Handler.GetEntities(); return Handler.GetEntities();
} }
/// <summary>
/// Get all achievements/advancements.
/// </summary>
/// <returns>Snapshot of all achievements</returns>
protected Achievement[] GetAchievements()
{
return Handler.GetAchievements();
}
/// <summary>
/// Get only completed achievements/advancements.
/// </summary>
/// <returns>Snapshot of unlocked achievements</returns>
protected Achievement[] GetUnlockedAchievements()
{
return Handler.GetUnlockedAchievements();
}
/// <summary>
/// Get only incomplete achievements/advancements.
/// </summary>
/// <returns>Snapshot of locked achievements</returns>
protected Achievement[] GetLockedAchievements()
{
return Handler.GetLockedAchievements();
}
/// <summary> /// <summary>
/// Get all players Latency /// Get all players Latency
/// </summary> /// </summary>

View file

@ -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. 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<Achievement> updated, IReadOnlyList<string> 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<Achievement> updated, IReadOnlyList<string> 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 ## 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). The authoritative reference for the C# API is [ChatBot.cs](https://github.com/MCCTeam/Minecraft-Console-Client/blob/master/MinecraftClient/Scripting/ChatBot.cs).

View file

@ -219,6 +219,54 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</details> </details>
<details>
<summary><code>achievement</code></summary>
- **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.
</details>
<details> <details>
<summary><code>bed</code></summary> <summary><code>bed</code></summary>