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/.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/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/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/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..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,19 @@ 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; + + case PacketTypesIn.SelectAdvancementTab: + HandleSelectAdvancementTab(packetData); + break; + default: return false; //Ignored packet } @@ -3139,6 +3154,218 @@ 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+). + /// + 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_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(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(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) + 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 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. + /// + 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); @@ -3290,6 +3517,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", @@ -3304,6 +3554,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/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 /// 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