Merge origin/master into feat/mcp-server

This commit is contained in:
Anon 2026-04-03 20:03:37 +02:00
commit f4c160979c
86 changed files with 16765 additions and 2758 deletions

View file

@ -21,13 +21,14 @@ jobs:
- name: Check skip CI
id: check-skip
run: |
MSG="${{ github.event.head_commit.message }}"
LOWER=$(echo "$MSG" | tr '[:upper:]' '[:lower:]')
LOWER=$(echo "$COMMIT_MSG" | tr '[:upper:]' '[:lower:]')
if echo "$LOWER" | grep -qE 'skip.?ci|ci.?skip'; then
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
fi
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
fetch-translations:
strategy:
@ -221,12 +222,13 @@ jobs:
- name: Truncate commit message for release name
id: release-name
run: |
RAW="${{ github.event.head_commit.message }}"
# Take only the first line (subject), then truncate to safe length
SUBJECT=$(echo "$RAW" | head -n 1)
MAX=220 # leave room for tag prefix + ": "
SUBJECT=$(echo "$COMMIT_MSG" | head -n 1)
MAX=220
TRUNCATED="${SUBJECT:0:$MAX}"
echo "name=${{ needs.create-tag.outputs.build-tag }}: $TRUNCATED" >> $GITHUB_OUTPUT
echo "name=${BUILD_TAG}: $TRUNCATED" >> $GITHUB_OUTPUT
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
BUILD_TAG: ${{ needs.create-tag.outputs.build-tag }}
- name: Create Release
uses: ncipollo/release-action@v1.14.0

5
.gitignore vendored
View file

@ -436,3 +436,8 @@ FodyWeavers.xsd
# SpecStory files
/.specstory/
/.vscode/settings.json
# Other
/Sentry/
/downloads/
server.pid

View file

@ -3,7 +3,6 @@ name: csharp-best-practices
description: >
C# 14 / .NET 10 coding conventions, idiomatic patterns, and performance best practices
for the Minecraft Console Client codebase. Use when writing, reviewing, or modifying C# code.
version: 0.4.0
---
# C# 14 / .NET 10 Best Practices

View file

@ -31,7 +31,6 @@ metadata:
- slow
- hang
- deadlock
version: 0.2.0
---
# C#/.NET CLI Optimization

View file

@ -7,7 +7,6 @@ metadata:
category: technique
triggers: performance, allocations, GC, hot path, latency, throughput,
memory pressure, optimize, slow, freeze, lag spike, packet processing speed
version: 0.2.0
---
# C# Performance Optimization for MCC

View file

@ -1,6 +1,5 @@
---
name: humanizer
version: 2.1.1
description: |
Remove signs of AI-generated writing from text. Use when editing or reviewing
text to make it sound more natural and human-written. Based on Wikipedia's

View file

@ -1,6 +1,6 @@
---
name: mcc-dev-workflow
description: Build, run, and debug Minecraft Console Client (MCC) against a real local Minecraft Java server in WSL. Use this whenever the user wants to compile MCC, start or inspect a local test server, connect MCC to a server, debug protocol or login issues, validate a code change end-to-end, or run MCC commands on a real server instead of guessing from static code.
description: Build, run, and debug Minecraft Console Client (MCC) against a real local Minecraft Java server on Linux, macOS, or WSL. Use this whenever the user wants to compile MCC, start or inspect a local test server, connect MCC to a server, debug protocol or login issues, validate a code change end-to-end, or run MCC commands on a real server instead of guessing from static code.
---
# MCC Development Workflow
@ -11,7 +11,7 @@ Use this skill when the task needs a real local server loop, not just code readi
- Solution: `MinecraftClient.sln`
- Runtime target: `.NET 10` / `net10.0`
- Environment: WSL Ubuntu, Java 21, tmux, python3
- Environment: Linux, macOS, or WSL with Java, tmux, python3, and dotnet available
- Default server root: `${MCC_SERVERS:-$MCC_REPO/MinecraftOfficial/downloads}`
- Default validation target when the user does not specify a version: `1.21.11`
@ -30,10 +30,22 @@ Both modes support the same commands and input/output through `ConsoleIO.Backend
- Prefer a real local server over static reasoning for protocol, login, movement, inventory, entity, or command-path work.
- Treat tmux `mc-*` sessions as shared state. Do not run multi-version server workflows in parallel unless the harness explicitly isolates them.
- For scripted or repeatable runs, prefer a temporary config copied from `MinecraftClient.ini`. Use the repo-root config only for ad hoc manual work.
- For scripted or repeatable runs, use a generated temporary config. Do not edit the repo-root `MinecraftClient.ini` as part of the test loop.
- A server log line containing `Done (` means startup finished. It does not guarantee that RCON is ready on the first attempt. Retry early `mc-rcon` commands.
- When instructions, docs, and code disagree, trust current code and current tool behavior first.
## Preflight and reset
Before scripted runs, especially on macOS or in a reused tmux environment:
```bash
source tools/mcc-env.sh
mcc-preflight 1.21.11
mc-reset-test-env 1.21.11
```
`mcc-preflight` checks Java, tmux, dotnet, python3, and server directories. It also resolves common Homebrew Java paths on macOS. `mc-reset-test-env` clears stale tmux sessions and stale `stdin.pipe` files before they turn into misleading startup failures.
## Build
```bash
@ -92,7 +104,7 @@ mcc-debug -v 1.21.11 --file-input --no-build
### What mcc-debug.sh does
1. Builds MCC (unless `--no-build`)
2. Creates a temp config at `/tmp/mcc-debug/MinecraftClient.debug.ini` with CursorBot account, Terrain/Inventory/Entity enabled
2. Creates a clean temp config at `/tmp/mcc-debug/MinecraftClient.debug.ini` with CursorBot account, Terrain/Inventory/Entity enabled and noisy bots disabled
3. Ensures server is running (starts if not, waits for `Done (`)
4. Launches MCC in the specified mode
@ -210,6 +222,9 @@ After `source tools/mcc-env.sh`:
| `mc-rcon "CMD"` | Send RCON command |
| `mc-kill VER` | Force-kill server tmux session |
| `mc-list` | List running MC server sessions |
| `mc-wait-ready VER [SEC]` | Wait for server `Done (` |
| `mc-wait-stop VER [SEC]` | Wait for server shutdown, with force-kill fallback |
| `mc-reset-test-env [--all|VER...]` | Reset shared tmux server state and stale pipes |
| `mcc-build` | Build MCC |
| `mcc-run [PORT]` | Run MCC classic+FileInput on port |
| `mcc-tui [PORT]` | Run MCC TUI mode in tmux |
@ -218,6 +233,7 @@ After `source tools/mcc-env.sh`:
| `mcc-debug [OPTS]` | One-step debug session (see above) |
| `mcc-log-mcc` | Tail MCC debug log |
| `mcc-state` | Send `debug state` and print last 30 log lines |
| `mcc-preflight [VER...]` | Verify Java, tmux, dotnet, python3, and server dirs |
## Temporary config recipe
@ -226,14 +242,10 @@ source tools/mcc-env.sh
TEST_ROOT="${TMPDIR:-/tmp}/mcc-dev"
CFG="$TEST_ROOT/MinecraftClient.1.21.11.ini"
mkdir -p "$TEST_ROOT"
cp "$MCC_REPO/MinecraftClient.ini" "$CFG"
sed -i \
-e 's/Account = { Login = "test", Password = "-" }/Account = { Login = "CursorBot", Password = "-" }/' \
-e 's/MinecraftVersion = "auto"/MinecraftVersion = "1.21.11"/' \
-e 's/TerrainAndMovements = false/TerrainAndMovements = true/' \
-e 's/InventoryHandling = false/InventoryHandling = true/' \
-e 's/EntityHandling = false/EntityHandling = true/' \
"$CFG"
bash "$MCC_REPO/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" \
"$CFG" \
"1.21.11" \
"CursorBot"
```
For TUI mode, also add:
@ -257,6 +269,8 @@ Basic command check:
mcc-cmd "inventory player list"
```
If a scripted run fails before MCC joins, check for a harness problem before assuming a product regression. Missing `mcc.log`, a pre-join `Connection refused`, or a server that never reached `Done (` usually means shared-state cleanup or startup failed.
## Typical debug loop
1. `source tools/mcc-env.sh`

View file

@ -56,7 +56,7 @@ If the environment cannot run a real server, say so and report the result as une
- Use a real local server.
- Launch MCC against an explicit `localhost:<server-port>` target for repeatable local tests.
- Keep version matrices sequential in shared local environments. The tmux server harness is shared state by default.
- Prefer temporary MCC configs for scripted runs so one test does not contaminate the next.
- Prefer generated temporary MCC configs for scripted runs so one test does not contaminate the next.
- Default to offline auth in generated temp configs. Do not trust the repo-root `MinecraftClient.ini` account defaults.
- If the user explicitly asks for Microsoft online login, honor that request and generate the temp config for Microsoft auth instead of offline mode.
- For Microsoft auth, prefer an interactive TTY launch with `BasicIO-NoColor` so the device code is easy to read and relay to the user.
@ -65,8 +65,10 @@ If the environment cannot run a real server, say so and report the result as une
- Legacy and modern command syntax differ. Do not assume one server-command profile fits every version.
- Use actual MCC output and actual server logs for assertions. Do not invent success strings.
- Treat server `Done` as startup progress, not RCON readiness. Retry the first RCON command before assuming the setup is broken.
- Run preflight before scripted test loops. On macOS, Java may be installed but not exported on PATH in the shell the harness uses.
- If a change touches shared routing or a version range, test at least one adjacent version that shares that path, or explicitly mark adjacent versions as unexecuted and inferred.
- For palette or version-content changes, probe at least one neighboring or existing item, entity, or block. Do not only check the headline addition.
- Separate product failures from harness failures. Missing logs, stale tmux state, stale `stdin.pipe`, or pre-join `Connection refused` errors are usually environment problems until proven otherwise.
## Choose the test mode
@ -117,11 +119,19 @@ Run them against a real server with a temp config and summarize counts from the
Before running any scenario:
0. run preflight and clear stale shared state when the environment is reused
1. configure the target server for offline testing
2. ensure `eula=true`
3. ensure RCON is enabled
4. build MCC unless the task explicitly reuses a fresh build
Preflight and reset helpers:
```bash
.skills/mcc-integration-testing/scripts/preflight_test_env.sh 1.21.11-Vanilla
.skills/mcc-integration-testing/scripts/reset_shared_test_state.sh 1.21.11-Vanilla
```
Offline configuration helper:
```bash
@ -141,8 +151,12 @@ Optionally override the login name with the fourth argument to the config helper
- `.skills/mcc-integration-testing/scripts/ensure_offline_server.sh`
- configures persistent offline mode and RCON
- `.skills/mcc-integration-testing/scripts/preflight_test_env.sh`
- verifies Java, tmux, dotnet, python3, server directories, and resolves common Java PATH issues
- `.skills/mcc-integration-testing/scripts/reset_shared_test_state.sh`
- clears stale tmux sessions and stale `stdin.pipe` files before a rerun
- `.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh`
- copies `MinecraftClient.ini`, prepares offline login by default, and can switch to Microsoft auth when explicitly requested
- generates a clean temporary MCC config, prepares offline login by default, disables noisy bots, and can switch to Microsoft auth when explicitly requested
- `.skills/mcc-integration-testing/scripts/get_server_port.sh`
- resolves the actual local server port from `server.properties` or the latest server log
- `.skills/mcc-integration-testing/scripts/run_full_spectrum_test.sh`
@ -159,6 +173,7 @@ In every report, separate:
- `Executed`: exact scripts, commands, versions, auth mode, and whether the run was sequential or single-version
- `Observed`: exact MCC output, exact server-log evidence, and the saved log directory
- `Inferred`: conclusions not directly shown by that run's runtime evidence
- `Harness issues`: setup or runner problems such as missing Java on PATH, stale tmux sessions, stale `stdin.pipe`, missing log artifacts, or failed config generation
Never upgrade inferred claims to observed facts. Absence of errors is supporting evidence only; pair it with a positive assertion for the feature under test.
@ -196,6 +211,7 @@ Always summarize:
## Troubleshooting
- If the first RCON command fails, retry it before assuming the setup is broken.
- If Java is installed but the harness still says it is missing, run `preflight_test_env.sh`. This resolves common Homebrew Java paths on macOS.
- If MCC reaches Microsoft device-code login during an offline test, stop and inspect the generated temp config before retrying.
- If the user explicitly requests Microsoft online login, set `MCC_TEST_ACCOUNT_TYPE=microsoft` before launching the harness.
- If the user explicitly requests Microsoft online login, use `BasicIO-NoColor` in a real TTY, relay the device code from the TUI, and avoid pressing empty Enter at any auth prompt.
@ -203,7 +219,8 @@ Always summarize:
- If `dotnet run` cannot see an existing Microsoft session, check whether `SessionCache.db` and `ProfileKeyCache.ini` need to be synced from `MinecraftClient/bin/Release/net10.0/` to the repo root.
- If Microsoft auth keeps prompting even with a valid session cache, verify `Account.Login` matches the cached username exactly.
- If MCC reports `Connection refused`, verify the launched target matches the server's actual `server-port`.
- If MCC reports `Connection refused` immediately after a server start, also check for stale shared state: old tmux sessions, a stale `stdin.pipe`, or a server that never actually reached `Done (`.
- If multiple versions are being tested, do not start them in parallel unless the harness isolates tmux sessions and input files.
- If a test assertion fails, inspect the real MCC output before changing the code or weakening the assertion.
- If an older server behaves oddly on Linux, check `use-native-transport=false` in `server.properties`.
- If a test should be repeatable, avoid mutating the repo-root `MinecraftClient.ini`.
- If a matrix row fails before producing `mcc.log` or a command transcript, treat it as a harness failure, fix the environment, and rerun that row before drawing product conclusions.

View file

@ -0,0 +1,110 @@
#!/usr/bin/env bash
sed_in_place() {
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
ensure_java_in_path() {
if command -v java >/dev/null 2>&1 && java -version >/dev/null 2>&1; then
return 0
fi
local candidate
for candidate in \
"${JAVA_BIN:-}" \
"/opt/homebrew/opt/openjdk/bin/java" \
"/usr/local/opt/openjdk/bin/java" \
"/usr/lib/jvm/default-java/bin/java"
do
[[ -z "$candidate" ]] && continue
if [[ -x "$candidate" ]]; then
export PATH="$(dirname "$candidate"):$PATH"
export JAVA_BIN="$candidate"
if java -version >/dev/null 2>&1; then
return 0
fi
fi
done
echo "java was not found on PATH. Install Java or set JAVA_BIN." >&2
return 1
}
server_session_name() {
printf 'mc-%s\n' "${1//./_}"
}
server_running() {
local version="$1"
mc-list | grep -Fq "$(server_session_name "$version")"
}
wait_for_server_ready() {
local version="$1"
local timeout="${2:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if mc-log "$version" 250 2>/dev/null | grep -Fq "Done ("; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for $version to become ready" >&2
return 1
}
wait_for_server_stop() {
local version="$1"
local timeout="${2:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if ! server_running "$version"; then
return 0
fi
sleep 1
((elapsed += 1))
done
mc-kill "$version" >/dev/null 2>&1 || true
if ! server_running "$version"; then
return 0
fi
echo "Timed out waiting for $version to stop" >&2
return 1
}
disable_noisy_bots_in_ini() {
local ini_file="$1"
local section
for section in \
ScriptScheduler \
DiscordRpc \
AntiAFK \
AutoDig \
AutoAttack \
PlayerListLogger \
ReplayCapture
do
sed_in_place "/^\\[ChatBot\\.${section}\\]/,/^\\[/ { s/^Enabled = true/Enabled = false/; }" "$ini_file"
done
}
remove_stale_stdin_pipe() {
local version="$1"
local pipe_path="$MCC_SERVERS/$version/stdin.pipe"
if [[ -e "$pipe_path" && ! -p "$pipe_path" ]]; then
rm -f "$pipe_path"
fi
}

View file

@ -5,6 +5,8 @@ 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"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
VERSION="${1:-1.21.11-Vanilla}"
SERVER_DIR="${MCC_SERVERS:?}/$VERSION"
@ -25,40 +27,12 @@ server_running() {
mc-list | grep -Fq "$SESSION_NAME"
}
wait_for_server_ready() {
local timeout="${1:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if mc-log "$VERSION" 200 2>/dev/null | grep -Fq "Done ("; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for $VERSION to become ready" >&2
return 1
}
wait_for_server_stop() {
local timeout="${1:-60}"
local elapsed=0
while (( elapsed < timeout )); do
if ! server_running; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for $VERSION to stop" >&2
return 1
}
upsert_property() {
local key="$1"
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
@ -66,14 +40,14 @@ upsert_property() {
if [[ ! -f "$PROPS_FILE" ]]; then
mc-start "$VERSION"
wait_for_server_ready
wait_for_server_ready "$VERSION"
mc-stop "$VERSION"
wait_for_server_stop
wait_for_server_stop "$VERSION"
fi
if server_running; then
mc-stop "$VERSION"
wait_for_server_stop
wait_for_server_stop "$VERSION"
fi
upsert_property "online-mode" "false"

View file

@ -0,0 +1,48 @@
#!/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"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF'
Usage: preflight_test_env.sh [server-dir...]
Checks the local MCC test environment and resolves common Java path issues.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
ensure_java_in_path
command -v tmux >/dev/null 2>&1 || { echo "tmux was not found on PATH." >&2; exit 1; }
command -v dotnet >/dev/null 2>&1 || { echo "dotnet was not found on PATH." >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "python3 was not found on PATH." >&2; exit 1; }
if [[ ! -d "$MCC_SERVERS" ]]; then
echo "Server root not found: $MCC_SERVERS" >&2
exit 1
fi
for server_dir in "$@"; do
[[ -z "$server_dir" ]] && continue
if [[ ! -d "$MCC_SERVERS/$server_dir" ]]; then
echo "Server directory not found: $MCC_SERVERS/$server_dir" >&2
exit 1
fi
remove_stale_stdin_pipe "$server_dir"
done
printf 'MCC_REPO=%s\n' "$MCC_REPO"
printf 'MCC_SERVERS=%s\n' "$MCC_SERVERS"
printf 'JAVA=%s\n' "$(command -v java)"
printf 'TMUX=%s\n' "$(command -v tmux)"
printf 'DOTNET=%s\n' "$(command -v dotnet)"

View file

@ -1,15 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -lt 3 || $# -gt 4 ]]; then
echo "Usage: $0 <template-ini> <output-ini> <mc-version> [login]" >&2
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF' >&2
Usage:
prepare_offline_mcc_config.sh <output-ini> <mc-version> [login]
prepare_offline_mcc_config.sh <template-ini> <output-ini> <mc-version> [login]
EOF
}
if [[ $# -lt 2 || $# -gt 4 ]]; then
usage
exit 1
fi
TEMPLATE_INI="$1"
OUTPUT_INI="$2"
MC_VERSION="$3"
LOGIN_NAME="${4:-CursorBot}"
TEMPLATE_INI=""
OUTPUT_INI=""
MC_VERSION=""
LOGIN_NAME=""
if [[ $# -ge 3 && -f "$1" ]]; then
TEMPLATE_INI="$1"
OUTPUT_INI="$2"
MC_VERSION="$3"
LOGIN_NAME="${4:-CursorBot}"
else
OUTPUT_INI="$1"
MC_VERSION="$2"
LOGIN_NAME="${3:-CursorBot}"
fi
ACCOUNT_TYPE="${MCC_TEST_ACCOUNT_TYPE:-mojang}"
PASSWORD_VALUE="${MCC_TEST_PASSWORD-}"
@ -26,9 +51,35 @@ if [[ -z "${MCC_TEST_PASSWORD+x}" ]]; then
fi
fi
generate_template_ini() {
local template_root
template_root="$(mktemp -d "${TMPDIR:-/tmp}/mcc-config-template.XXXXXX")"
if [[ ! -f "$REPO_ROOT/MinecraftClient/bin/Release/net10.0/MinecraftClient.dll" ]]; then
dotnet build "$REPO_ROOT/MinecraftClient.sln" -c Release -v quiet --nologo >/dev/null
fi
(
cd "$template_root"
dotnet run --project "$REPO_ROOT/MinecraftClient" -c Release --no-build -- --help >/dev/null 2>&1
)
if [[ ! -f "$template_root/MinecraftClient.ini" ]]; then
echo "Failed to generate a temporary MCC config template." >&2
exit 1
fi
TEMPLATE_INI="$template_root/MinecraftClient.ini"
}
if [[ -z "$TEMPLATE_INI" ]]; then
generate_template_ini
fi
mkdir -p "$(dirname "$OUTPUT_INI")"
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#" \
@ -38,6 +89,8 @@ sed -i \
-e 's#^AutoRespawn = false#AutoRespawn = true#' \
"$OUTPUT_INI"
disable_noisy_bots_in_ini "$OUTPUT_INI"
grep -Fq "AccountType = \"$ACCOUNT_TYPE\"" "$OUTPUT_INI" || {
echo "Failed to enforce account type $ACCOUNT_TYPE in $OUTPUT_INI" >&2
exit 1

View file

@ -0,0 +1,50 @@
#!/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"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
usage() {
cat <<'EOF'
Usage: reset_shared_test_state.sh [--all | <server-dir>...]
Kills shared tmux test sessions and removes stale stdin pipes.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
kill_named_session() {
local session_name="$1"
tmux kill-session -t "$session_name" 2>/dev/null || true
}
kill_named_session "mcc-debug"
if [[ $# -eq 0 || "${1:-}" == "--all" ]]; then
while IFS= read -r session_name; do
[[ -z "$session_name" ]] && continue
kill_named_session "$session_name"
done < <(tmux list-sessions 2>/dev/null | awk -F: '/^mc-/{print $1}' || true)
while IFS= read -r pipe_path; do
[[ -z "$pipe_path" ]] && continue
if [[ ! -p "$pipe_path" ]]; then
rm -f "$pipe_path"
fi
done < <(find "$MCC_SERVERS" -maxdepth 2 -name 'stdin.pipe' 2>/dev/null || true)
else
for version in "$@"; do
kill_named_session "$(server_session_name "$version")"
remove_stale_stdin_pipe "$version"
done
fi
rm -f "$MCC_REPO/mcc_input.txt"

View file

@ -0,0 +1,168 @@
#!/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"
if [[ -n "${MCC_LOG:-}" && ! -f "$MCC_LOG" ]]; then
NOTE="Harness failure: MCC log was not produced."
VERDICT="❌ Fail"
fi
if [[ -n "${COMMAND_LOG:-}" && ! -f "$COMMAND_LOG" ]]; then
NOTE="Harness failure: command transcript was not produced."
VERDICT="❌ Fail"
fi
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
bash "$SCRIPT_DIR/preflight_test_env.sh" >/dev/null 2>&1 || true
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,396 @@
#!/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"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
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
wait_for_server_stop "$SERVER_DIR" 20 >/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
}
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 $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
bash "$SCRIPT_DIR/preflight_test_env.sh" "$SERVER_DIR" >/dev/null || fail "Test environment preflight failed."
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$SERVER_DIR" >/dev/null || fail "Failed to reset shared test state."
if [[ ! -d "$MCC_SERVERS/$SERVER_DIR" ]]; then
fail "Server directory not found: $MCC_SERVERS/$SERVER_DIR"
fi
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" CursorBot >/dev/null || fail "Failed to prepare temporary MCC config."
PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$SERVER_DIR")"
"$SCRIPT_DIR/ensure_offline_server.sh" "$SERVER_DIR"
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
: > "$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 "$SERVER_DIR" || 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 -- \
"$CFG" \
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

@ -5,6 +5,8 @@ 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"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$SCRIPT_DIR/common.sh"
VERSION="${1:-1.21.11-Vanilla}"
MC_VERSION="${VERSION%-Vanilla}"
@ -34,46 +36,12 @@ cleanup() {
fi
mc-stop "$VERSION" >/dev/null 2>&1 || true
wait_for_server_stop "$VERSION" 20 >/dev/null 2>&1 || true
}
trap cleanup EXIT
prepare_config() {
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$REPO_ROOT/MinecraftClient.ini" "$CFG" "$MC_VERSION" >/dev/null
}
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 "$VERSION" 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
bash "$SCRIPT_DIR/prepare_offline_mcc_config.sh" "$CFG" "$MC_VERSION" CursorBot >/dev/null
}
wait_for_server_log_pattern() {
@ -101,6 +69,25 @@ capture_server_logs() {
fi
}
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
}
fail() {
capture_server_logs
echo "FAIL: $1" >&2
@ -146,18 +133,19 @@ run_mcc_command() {
sleep 2
}
bash "$SCRIPT_DIR/preflight_test_env.sh" "$VERSION" >/dev/null
bash "$SCRIPT_DIR/reset_shared_test_state.sh" "$VERSION" >/dev/null
"$SCRIPT_DIR/ensure_offline_server.sh" "$VERSION"
echo "Building MCC..."
mcc-build > "$BUILD_LOG" 2>&1 || fail "mcc-build failed"
prepare_config
SERVER_PORT="$(bash "$SCRIPT_DIR/get_server_port.sh" "$VERSION")"
: > "$INPUT_FILE"
echo "Building MCC..."
mcc-build > "$BUILD_LOG" 2>&1 || fail "mcc-build failed"
echo "Starting server..."
mc-start "$VERSION" >/dev/null
wait_for_server_ready || fail "Server did not become ready"
wait_for_server_ready "$VERSION" || fail "Server did not become ready"
echo "Starting MCC..."
(

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 "- Rows with missing MCC or command-log artifacts should be treated as harness failures until rerun confirms a product issue."

View file

@ -15,6 +15,7 @@ Systematic workflow for updating Minecraft Console Client to support a new Minec
$MCC_REPO/tools/decompile.sh --version <ver>
```
This auto-downloads `MinecraftDecompiler.jar` if needed, produces the decompiled source, and downloads `server.jar` into `$MCC_SERVERS/<ver>/`.
- `tools/decompile.sh` depends on official mappings. For older versions where it refuses to decompile, fall back to a raw Java decompiler such as `cfr-decompiler` against `$MCC_SERVERS/<ver>/server.jar`. That fallback is good enough for packet inspection and registration order checks even when the output is obfuscated.
- A test server of the target version in `$MCC_SERVERS/<version>/` (see `mcc-dev-workflow` skill)
## Step 0: Generate Server Reports (CRITICAL since 1.21.9)
@ -215,7 +216,42 @@ The JSON maps block names (snake_case) → collision shape IDs → AABB coordina
**Data source**: PrismarineJS `minecraft-data` repo, path: `data/pc/<version>/blockCollisionShapes.json`. Version availability can be checked via `data/dataPaths.json`.
## Step 9: Compile and Verify
## Step 9: Update Minimap Block Color Map
Regenerate the block-to-MapColor mapping used by the TUI minimap. This maps each block's `Material` enum to the RGB color from Minecraft's official `MapColor` table.
```bash
python3 $MCC_REPO/tools/gen_block_color_map.py $MCC_REPO/MinecraftOfficial/<version>-decompiled
# e.g. python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled
```
Output: `MinecraftClient/Tui/MinimapBlockColors.json` (embedded as a resource via `.csproj`).
The script parses `MapColor.java`, `DyeColor.java`, and `Blocks.java` from the decompiled source to extract each block's assigned map color. Blocks not matched to a known `Material` enum value are skipped.
**When to update**: Whenever new blocks are added or existing blocks change their `mapColor()` assignment. If only items or entities changed, this step can be skipped.
## Step 10: Update Minimap Entity Categories
Regenerate the entity-to-MobCategory mapping used by the TUI minimap for classifying entities as hostile, passive, neutral, or non-living.
```bash
python3 $MCC_REPO/tools/gen_entity_category_map.py $MCC_REPO/MinecraftOfficial/<version>-decompiled
# e.g. python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled
```
Output: `MinecraftClient/Tui/MinimapEntityCategories.json` (embedded as a resource via `.csproj`).
The script parses `EntityType.java` to extract each entity's `MobCategory` assignment, then maps Minecraft's categories to MCC minimap categories:
- `MONSTER` -> hostile (with neutral overrides for conditionally hostile mobs like Enderman, Spider, Wolf)
- `CREATURE`/`AMBIENT`/`AXOLOTLS`/`WATER_*` -> passive
- `MISC` -> non_living (with passive overrides for Villager, WanderingTrader, ZombieHorse)
The script maintains manual override lists for "neutral" mobs (attack only when provoked) since Minecraft has no machine-readable flag for this behavior. Review and update the `NEUTRAL_OVERRIDES` and `PASSIVE_OVERRIDES` sets in the script when new conditionally-hostile or misclassified mobs are added.
**When to update**: Whenever new entity types are added. If only blocks or items changed, this step can be skipped.
## Step 11: Compile and Verify
```bash
dotnet build $MCC_REPO/MinecraftClient.sln -c Release
@ -274,3 +310,5 @@ All scripts are in `$MCC_REPO/tools/`. See `tools/README.md` for detailed usage.
| `gen_entity_palette.py` | Generate EntityPalette C# | registries.json |
| `gen_entity_metadata_palette.py` | Generate EntityMetadataPalette C# | Decompiled source |
| `gen_block_shapes.py` | Download & compact block collision shapes | PrismarineJS minecraft-data |
| `gen_block_color_map.py` | Generate minimap block color JSON | Decompiled source (MapColor/DyeColor/Blocks) |
| `gen_entity_category_map.py` | Generate minimap entity category JSON | Decompiled source (EntityType.java) |

View file

@ -1,10 +1,6 @@
---
name: writing-skills
description: "Use when creating, updating, or improving agent skills."
category: meta
risk: unknown
source: community
date_added: "2026-02-27"
---
# Writing Skills (Excellence)

View file

@ -4,6 +4,7 @@
- Minecraft Console Client (MCC) is a cross-platform text/TUI client for Minecraft Java Edition.
- Primary scope: connect to servers, send chat and commands, receive text, automate gameplay/admin tasks, and extend behavior through built-in bots or runtime C# scripts.
- Secondary scope: protocol/version adaptation tooling, docs site, legacy GUI wrapper, and debug tooling.
- Decompiled server source for both the old and new MC versions in `$MCC_REPO/MinecraftOfficial/<version>-decompiled/`
## Build / Run
- Init submodules first: `git submodule update --init --recursive`

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

@ -5,7 +5,9 @@ using System.Threading;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.CommandHandler.Patch;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
using MinecraftClient.Scripting;
using Tomlet.Attributes;
@ -25,15 +27,12 @@ namespace MinecraftClient.ChatBots
public bool Enabled = false;
[NonSerialized]
[TomlInlineComment("$ChatBot.AutoDig.Auto_Tool_Switch$")]
public bool Auto_Tool_Switch = false;
[NonSerialized]
[TomlInlineComment("$ChatBot.AutoDig.Durability_Limit$")]
public int Durability_Limit = 2;
[NonSerialized]
[TomlInlineComment("$ChatBot.AutoDig.Drop_Low_Durability_Tools$")]
public bool Drop_Low_Durability_Tools = false;
@ -65,6 +64,8 @@ namespace MinecraftClient.ChatBots
public void OnSettingUpdate()
{
Durability_Limit = Math.Max(0, Durability_Limit);
if (Auto_Start_Delay >= 0)
Auto_Start_Delay = Math.Max(0.1, Auto_Start_Delay);
@ -225,6 +226,102 @@ namespace MinecraftClient.ChatBots
}
}
private static int GetLegacyMaxDamage(ItemType itemType)
{
return itemType switch
{
ItemType.WoodenPickaxe or ItemType.WoodenAxe or ItemType.WoodenShovel or ItemType.WoodenSword or ItemType.WoodenHoe => 59,
ItemType.StonePickaxe or ItemType.StoneAxe or ItemType.StoneShovel or ItemType.StoneSword or ItemType.StoneHoe => 131,
ItemType.IronPickaxe or ItemType.IronAxe or ItemType.IronShovel or ItemType.IronSword or ItemType.IronHoe => 250,
ItemType.GoldenPickaxe or ItemType.GoldenAxe or ItemType.GoldenShovel or ItemType.GoldenSword or ItemType.GoldenHoe => 32,
ItemType.DiamondPickaxe or ItemType.DiamondAxe or ItemType.DiamondShovel or ItemType.DiamondSword or ItemType.DiamondHoe => 1561,
ItemType.NetheritePickaxe or ItemType.NetheriteAxe or ItemType.NetheriteShovel or ItemType.NetheriteSword or ItemType.NetheriteHoe => 2031,
ItemType.Shears => 238,
_ => 0
};
}
private static int GetMaxDamage(Item item)
{
if (item.Components is not null)
{
var maxDamageComponent = item.Components.OfType<MaxDamageComponent>().FirstOrDefault();
if (maxDamageComponent is not null)
return maxDamageComponent.MaxDamage;
}
return GetLegacyMaxDamage(item.Type);
}
private static int GetRemainingDurability(Item item)
{
int maxDamage = GetMaxDamage(item);
return maxDamage > 0 ? maxDamage - item.Damage : int.MaxValue;
}
private bool HasEnoughDurability(Item item)
{
return Config.Durability_Limit <= 0 || GetRemainingDurability(item) >= Config.Durability_Limit;
}
private bool IsBelowDurabilityLimit(Item? item)
{
return item is not null && Config.Durability_Limit > 0 && GetRemainingDurability(item) < Config.Durability_Limit;
}
private static bool IsRecommendedTool(Item? item, ItemType[] recommendedTools)
{
return item is not null && recommendedTools.Contains(item.Type);
}
private bool SwapToolIntoHand(int sourceSlot, int handSlot)
{
return WindowAction(0, sourceSlot, WindowActionType.LeftClick)
&& WindowAction(0, handSlot, WindowActionType.LeftClick)
&& WindowAction(0, sourceSlot, WindowActionType.LeftClick);
}
private bool EnsureSuitableTool(Material blockType)
{
if (!inventoryEnabled || !Config.Auto_Tool_Switch)
return true;
ItemType[] recommendedTools = Material2Tool.GetCorrectToolForBlock(blockType);
if (recommendedTools.Length == 0)
return true;
Container container = GetPlayerInventory();
int handSlot = 36 + GetCurrentSlot();
container.Items.TryGetValue(handSlot, out Item? currentTool);
if (currentTool is not null && IsRecommendedTool(currentTool, recommendedTools) && HasEnoughDurability(currentTool))
return true;
foreach (ItemType recommendedTool in recommendedTools)
{
foreach ((int slot, Item item) in container.Items)
{
if (slot == handSlot || item.Type != recommendedTool || !HasEnoughDurability(item))
continue;
if (!SwapToolIntoHand(slot, handSlot))
return false;
LogToConsole(GetTimestamp() + ": " + string.Format(Translations.bot_autodig_switch, item.GetTypeString(), slot));
if (Config.Drop_Low_Durability_Tools && IsBelowDurabilityLimit(currentTool) &&
WindowAction(0, slot, WindowActionType.DropItemStack))
{
LogToConsole(GetTimestamp() + ": " + string.Format(Translations.bot_autodig_drop_low_durability, currentTool!.GetTypeString(), slot));
}
return true;
}
}
return !IsBelowDurabilityLimit(currentTool);
}
public override void Update()
{
lock (stateLock)
@ -293,6 +390,9 @@ namespace MinecraftClient.ChatBots
if (Config.Mode == Configs.ModeType.lookat ||
(Config.Mode == Configs.ModeType.both && Config._Locations.Contains(blockLoc)))
{
if (!EnsureSuitableTool(block.Type))
return false;
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: false))
{
currentDig = blockLoc;
@ -354,6 +454,9 @@ namespace MinecraftClient.ChatBots
if (minDistance <= 6.0)
{
if (!EnsureSuitableTool(targetBlock.Type))
return false;
if (DigBlock(target, Direction.Down, lookAtBlock: true))
{
currentDig = target;
@ -388,6 +491,9 @@ namespace MinecraftClient.ChatBots
((Config.List_Type == Configs.ListType.whitelist && Config.Blocks.Contains(block.Type)) ||
(Config.List_Type == Configs.ListType.blacklist && !Config.Blocks.Contains(block.Type))))
{
if (!EnsureSuitableTool(block.Type))
return false;
if (DigBlock(blockLoc, Direction.Down, lookAtBlock: true))
{
currentDig = blockLoc;

View file

@ -62,6 +62,21 @@ namespace MinecraftClient.ChatBots
[TomlInlineComment("$ChatBot.AutoFishing.Hook_Threshold$")]
public double Hook_Threshold = 0.2;
[TomlInlineComment("$ChatBot.AutoFishing.Enable_Velocity_Detection$")]
public bool Enable_Velocity_Detection = true;
[TomlInlineComment("$ChatBot.AutoFishing.Velocity_Hook_Threshold$")]
public double Velocity_Hook_Threshold = -0.2;
[TomlInlineComment("$ChatBot.AutoFishing.Enable_Sound_Detection$")]
public bool Enable_Sound_Detection = true;
[TomlInlineComment("$ChatBot.AutoFishing.Sound_Distance$")]
public double Sound_Distance = 5.0;
[TomlInlineComment("$ChatBot.AutoFishing.Detection_Warmup$")]
public double Detection_Warmup = 1.0;
[TomlInlineComment("$ChatBot.AutoFishing.Log_Fish_Bobber$")]
public bool Log_Fish_Bobber = false;
@ -97,6 +112,15 @@ namespace MinecraftClient.ChatBots
if (Hook_Threshold < 0)
Hook_Threshold = -Hook_Threshold;
if (Velocity_Hook_Threshold > 0)
Velocity_Hook_Threshold = -Velocity_Hook_Threshold;
if (Sound_Distance < 0)
Sound_Distance = -Sound_Distance;
if (Detection_Warmup < 0)
Detection_Warmup = 0;
}
public struct LocationConfig
@ -171,6 +195,7 @@ namespace MinecraftClient.ChatBots
private Entity? fishingBobber;
private Location LastPos = Location.Zero;
private DateTime CaughtTime = DateTime.Now;
private DateTime BobberSpawnTime = DateTime.MinValue;
private int fishItemCounter = 15;
private Dictionary<ItemType, uint> fishItemCnt = new();
private Entity fishItem = new(-1, EntityType.Item, Location.Zero);
@ -464,6 +489,7 @@ namespace MinecraftClient.ChatBots
fishingBobber = entity;
LastPos = entity.Location;
isFishing = true;
BobberSpawnTime = DateTime.Now;
castTimeout = 24;
counter = 0;
@ -500,7 +526,7 @@ namespace MinecraftClient.ChatBots
public override void OnEntityMove(Entity entity)
{
if (isFishing && entity is not null && fishingBobber!.ID == entity.ID &&
(state == FishingState.WaitingFishToBite || state == FishingState.WaitingFishingBobber))
state == FishingState.WaitingFishToBite)
{
Location Pos = entity.Location;
double Dx = LastPos.X - Pos.X;
@ -515,13 +541,7 @@ namespace MinecraftClient.ChatBots
Math.Abs(Dz) < Math.Abs(Config.Stationary_Threshold) &&
Math.Abs(Dy) > Math.Abs(Config.Hook_Threshold))
{
// prevent triggering multiple time
if ((DateTime.Now - CaughtTime).TotalSeconds > 1)
{
isFishing = false;
CaughtTime = DateTime.Now;
OnCaughtFish();
}
TryCatchFish();
}
}
}
@ -540,6 +560,38 @@ namespace MinecraftClient.ChatBots
}
}
public override void OnEntityVelocity(Entity entity, double velocityX, double velocityY, double velocityZ)
{
if (!Config.Enable_Velocity_Detection || !CanUseAdvancedDetection())
return;
if (fishingBobber is null || entity.ID != fishingBobber.ID)
return;
if (velocityY <= Config.Velocity_Hook_Threshold)
TryCatchFish();
}
public override void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch,
Entity? sourceEntity)
{
if (!Config.Enable_Sound_Detection || !CanUseAdvancedDetection())
return;
if (!IsFishingBobberSplashSound(soundName))
return;
Location? soundLocation = location;
if (soundLocation is null && sourceEntity is not null)
soundLocation = sourceEntity.Location;
if (soundLocation is null || fishingBobber is null)
return;
if (soundLocation.Value.Distance(fishingBobber.Location) <= Config.Sound_Distance)
TryCatchFish();
}
public override void AfterGameJoined()
{
StartFishing();
@ -562,10 +614,42 @@ namespace MinecraftClient.ChatBots
fishingBobber = null;
LastPos = Location.Zero;
CaughtTime = DateTime.Now;
BobberSpawnTime = DateTime.MinValue;
return base.OnDisconnect(reason, message);
}
private bool CanUseAdvancedDetection()
{
if (!isFishing || fishingBobber is null || state != FishingState.WaitingFishToBite)
return false;
return (DateTime.Now - BobberSpawnTime).TotalSeconds >= Config.Detection_Warmup;
}
private void TryCatchFish()
{
if (!CanUseAdvancedDetection())
return;
// Prevent repeated catches from multiple packets of the same bite.
if ((DateTime.Now - CaughtTime).TotalSeconds <= 1)
return;
isFishing = false;
CaughtTime = DateTime.Now;
OnCaughtFish();
}
private static bool IsFishingBobberSplashSound(string? soundName)
{
return string.Equals(soundName, "minecraft:entity.fishing_bobber.splash",
StringComparison.OrdinalIgnoreCase)
|| string.Equals(soundName, "entity.fishing_bobber.splash", StringComparison.OrdinalIgnoreCase)
|| string.Equals(soundName, "minecraft:entity.bobber.splash", StringComparison.OrdinalIgnoreCase)
|| string.Equals(soundName, "entity.bobber.splash", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Called when detected a fish is caught
/// </summary>

View file

@ -1,4 +1,4 @@
using System;
using System;
using MinecraftClient.Scripting;
using Tomlet.Attributes;
@ -95,6 +95,11 @@ namespace MinecraftClient.ChatBots
_Initialize();
}
public override void AfterGameJoined()
{
Configs._BotRecoAttempts = 0;
}
private void _Initialize()
{
McClient.ReconnectionAttemptsLeft = Config.Retries;
@ -144,10 +149,17 @@ namespace MinecraftClient.ChatBots
{
double delay = random.NextDouble() * (Config.Delay.max - Config.Delay.min) + Config.Delay.min;
LogDebugToConsole(string.Format(string.IsNullOrEmpty(msg) ? Translations.bot_autoRelog_reconnect_always : Translations.bot_autoRelog_reconnect, msg));
// TODO: Change this translation string to add the retries left text
LogToConsole(string.Format(Translations.bot_autoRelog_wait, delay) + $" ({Config.Retries - Configs._BotRecoAttempts} retries left)");
ReconnectToTheServer(Config.Retries - Configs._BotRecoAttempts, (int)Math.Floor(delay), true);
int retriesLeft = Config.Retries - Configs._BotRecoAttempts;
if (retriesLeft < 0)
retriesLeft = 0;
string retriesDisplay = Config.Retries == int.MaxValue
? Translations.bot_autoRelog_retries_unlimited
: retriesLeft.ToString();
LogToConsole(string.Format(Translations.bot_autoRelog_wait_with_retries, delay, retriesDisplay));
ReconnectToTheServer(retriesLeft, (int)Math.Floor(delay), true);
}
public static bool OnDisconnectStatic(DisconnectReason reason, string message)

View file

@ -1,8 +1,11 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Brigadier.NET.Builder;
using DSharpPlus;
@ -34,6 +37,9 @@ namespace MinecraftClient.ChatBots
private DiscordChannel? discordChannel;
private BridgeDirection bridgeDirection = BridgeDirection.Both;
private readonly ConcurrentQueue<string> aggregationBuffer = new();
private Timer? aggregationTimer;
public static Configs Config = new();
[TomlDoNotInlineObject]
@ -62,6 +68,12 @@ namespace MinecraftClient.ChatBots
[TomlInlineComment("$ChatBot.DiscordBridge.AllowOtherBotMessages$")]
public bool Allow_Other_Bot_Messages = false;
[TomlInlineComment("$ChatBot.DiscordBridge.RelayAllMessages$")]
public bool Relay_All_Messages = false;
[TomlInlineComment("$ChatBot.DiscordBridge.MessageAggregationInterval$")]
public double Message_Aggregation_Interval = 3.0;
[TomlPrecedingComment("$ChatBot.DiscordBridge.Formats$")]
public string PrivateMessageFormat = "**[Private Message]** {username}: {message}";
public string PublicMessageFormat = "{username}: {message}";
@ -70,6 +82,8 @@ namespace MinecraftClient.ChatBots
public void OnSettingUpdate()
{
Message_Send_Timeout = Message_Send_Timeout <= 0 ? 3 : Message_Send_Timeout;
if (Message_Aggregation_Interval < 0)
Message_Aggregation_Interval = 0;
}
}
@ -100,6 +114,12 @@ namespace MinecraftClient.ChatBots
.Redirect(McClient.dispatcher.GetRoot().GetChild("help").GetChild(CommandName)))
);
if (Config.Message_Aggregation_Interval > 0)
{
var intervalMs = (int)(Config.Message_Aggregation_Interval * 1000);
aggregationTimer = new Timer(_ => FlushAggregationBuffer(), null, intervalMs, intervalMs);
}
Task.Run(async () => await MainAsync());
}
@ -107,6 +127,7 @@ namespace MinecraftClient.ChatBots
{
McClient.dispatcher.Unregister(CommandName);
McClient.dispatcher.GetRoot().GetChild("help").RemoveChild(CommandName);
StopAggregation();
Disconnect();
}
@ -147,6 +168,40 @@ namespace MinecraftClient.ChatBots
return r.SetAndReturn(CmdResult.Status.Done, string.Format(Translations.bot_DiscordBridge_direction, bridgeName));
}
private void FlushAggregationBuffer()
{
if (aggregationBuffer.IsEmpty || !CanSendMessages())
return;
var sb = new StringBuilder();
while (aggregationBuffer.TryDequeue(out var line))
{
if (sb.Length + line.Length + 1 > 1900)
{
SendMessage(sb.ToString());
sb.Clear();
}
if (sb.Length > 0)
sb.AppendLine();
sb.Append(line);
}
if (sb.Length > 0)
SendMessage(sb.ToString());
}
private void StopAggregation()
{
if (aggregationTimer is not null)
{
aggregationTimer.Dispose();
aggregationTimer = null;
}
FlushAggregationBuffer();
}
~DiscordBridge()
{
Disconnect();
@ -188,7 +243,6 @@ namespace MinecraftClient.ChatBots
text = GetVerbatim(text).Trim();
// Stop the crash when an empty text is recived somehow
if (string.IsNullOrEmpty(text))
return;
@ -205,7 +259,10 @@ namespace MinecraftClient.ChatBots
message = Config.TeleportRequestMessageFormat.Replace("{username}", username).Replace("{timestamp}", GetTimestamp()).Trim();
teleportRequest = true;
}
else message = text;
else if (Config.Relay_All_Messages)
message = text;
else
return;
if (teleportRequest)
{
@ -223,7 +280,13 @@ namespace MinecraftClient.ChatBots
SendMessage(messageBuilder);
return;
}
else SendMessage(GetDiscordText(message));
string discordText = GetDiscordText(message);
if (Config.Message_Aggregation_Interval > 0)
aggregationBuffer.Enqueue(discordText);
else
SendMessage(discordText);
}
/// <summary>

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,284 @@
using System;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Tui;
using Avalonia.Threading;
using static MinecraftClient.CommandHandler.CmdResult;
namespace MinecraftClient.Commands
{
class Minimap : Command
{
public override string CmdName => "minimap";
public override string CmdUsage => "minimap [on|off] | minimap zoom [in|out|<1-16>] | minimap names [players|hostile|neutral|passive] [on|off] | minimap names [all_on|all_off] | minimap position [top_left|top_right|center|bottom_left|bottom_right] | minimap cave [auto|on|off]";
public override string CmdDesc => Translations.cmd_minimap_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))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => DoToggle(r.Source))
.Then(l => l.Literal("on")
.Executes(r => DoOn(r.Source)))
.Then(l => l.Literal("off")
.Executes(r => DoOff(r.Source)))
.Then(l => l.Literal("zoom")
.Executes(r => DoZoomInfo(r.Source))
.Then(l => l.Literal("in")
.Executes(r => DoZoomIn(r.Source)))
.Then(l => l.Literal("out")
.Executes(r => DoZoomOut(r.Source)))
.Then(l => l.Argument("level", Arguments.Integer(MinimapControl.MinZoom, MinimapControl.MaxZoom))
.Executes(r => DoZoomSet(r.Source, Arguments.GetInteger(r, "level")))))
.Then(l => l.Literal("names")
.Executes(r => DoNamesInfo(r.Source))
.Then(l => l.Literal("all_on")
.Executes(r => DoNamesAll(r.Source, true)))
.Then(l => l.Literal("all_off")
.Executes(r => DoNamesAll(r.Source, false)))
.Then(l => l.Literal("players")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Player))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Player, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Player, false))))
.Then(l => l.Literal("hostile")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Hostile))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Hostile, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Hostile, false))))
.Then(l => l.Literal("neutral")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Neutral))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Neutral, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Neutral, false))))
.Then(l => l.Literal("passive")
.Executes(r => DoNamesCatInfo(r.Source, MobCategory.Passive))
.Then(l => l.Literal("on")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Passive, true)))
.Then(l => l.Literal("off")
.Executes(r => DoNamesCatSet(r.Source, MobCategory.Passive, false)))))
.Then(l => l.Literal("position")
.Executes(r => DoPositionInfo(r.Source))
.Then(l => l.Literal("top_left")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.top_left)))
.Then(l => l.Literal("top_right")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.top_right)))
.Then(l => l.Literal("center")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.center)))
.Then(l => l.Literal("bottom_left")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.bottom_left)))
.Then(l => l.Literal("bottom_right")
.Executes(r => DoPositionSet(r.Source, MinimapPosition.bottom_right))))
.Then(l => l.Literal("cave")
.Executes(r => DoCaveInfo(r.Source))
.Then(l => l.Literal("auto")
.Executes(r => DoCaveSet(r.Source, CaveModeOption.auto)))
.Then(l => l.Literal("on")
.Executes(r => DoCaveSet(r.Source, CaveModeOption.on)))
.Then(l => l.Literal("off")
.Executes(r => DoCaveSet(r.Source, CaveModeOption.off))))
.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 _) =>
r.SetAndReturn(GetCmdDescTranslated());
private static MainTuiView? GetTuiView(CmdResult r)
{
if (ConsoleIO.Backend is not TuiConsoleBackend)
{
r.SetAndReturn(Status.Fail, Translations.cmd_minimap_tui_only);
return null;
}
return TuiConsoleBackend.Instance?.GetView();
}
private static int DoToggle(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
bool wasVisible = view.IsMinimapVisible;
Dispatcher.UIThread.Post(() => view.ToggleMinimap());
string msg = wasVisible
? Translations.cmd_minimap_disabled
: Translations.cmd_minimap_enabled;
return r.SetAndReturn(Status.Done, msg);
}
private static int DoOn(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.ShowMinimap());
return r.SetAndReturn(Status.Done, Translations.cmd_minimap_enabled);
}
private static int DoOff(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.HideMinimap());
return r.SetAndReturn(Status.Done, Translations.cmd_minimap_disabled);
}
private static int DoZoomInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
int current = view.GetMinimapZoom();
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_zoom_current, current, MinimapControl.MaxZoom));
}
private static int DoZoomIn(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
int newLevel = Math.Max(view.GetMinimapZoom() - 1, MinimapControl.MinZoom);
Dispatcher.UIThread.Post(() => view.SetMinimapZoom(newLevel));
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, newLevel));
}
private static int DoZoomOut(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
int newLevel = Math.Min(view.GetMinimapZoom() + 1, MinimapControl.MaxZoom);
Dispatcher.UIThread.Post(() => view.SetMinimapZoom(newLevel));
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, newLevel));
}
private static int DoZoomSet(CmdResult r, int level)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.SetMinimapZoom(level));
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_minimap_zoom_set, level));
}
private static int DoNamesInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var nc = view.GetMinimapNameConfig();
string status = string.Format(Translations.cmd_minimap_names_status,
BoolStr(nc.Players), BoolStr(nc.Hostile), BoolStr(nc.Neutral), BoolStr(nc.Passive));
return r.SetAndReturn(Status.Done, status);
}
private static int DoNamesAll(CmdResult r, bool on)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() =>
{
view.GetMinimapNameConfig().SetAll(on);
view.SyncMinimapNameConfig();
});
string msg = on ? Translations.cmd_minimap_names_all_on : Translations.cmd_minimap_names_all_off;
return r.SetAndReturn(Status.Done, msg);
}
private static int DoNamesCatInfo(CmdResult r, MobCategory cat)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var nc = view.GetMinimapNameConfig();
bool val = cat switch
{
MobCategory.Player => nc.Players,
MobCategory.Hostile => nc.Hostile,
MobCategory.Neutral => nc.Neutral,
MobCategory.Passive => nc.Passive,
_ => false,
};
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_names_cat, cat, BoolStr(val)));
}
private static int DoNamesCatSet(CmdResult r, MobCategory cat, bool on)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() =>
{
var nc = view.GetMinimapNameConfig();
switch (cat)
{
case MobCategory.Player: nc.Players = on; break;
case MobCategory.Hostile: nc.Hostile = on; break;
case MobCategory.Neutral: nc.Neutral = on; break;
case MobCategory.Passive: nc.Passive = on; break;
}
view.SyncMinimapNameConfig();
});
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_names_cat_set, cat, BoolStr(on)));
}
private static int DoPositionInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var pos = view.GetMinimapPosition();
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_position_current, pos));
}
private static int DoPositionSet(CmdResult r, MinimapPosition pos)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.SetMinimapPosition(pos));
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_position_set, pos));
}
private static int DoCaveInfo(CmdResult r)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
var mode = view.GetMinimapCaveMode();
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_cave_current, mode));
}
private static int DoCaveSet(CmdResult r, CaveModeOption mode)
{
var view = GetTuiView(r);
if (view is null) return (int)r.status;
Dispatcher.UIThread.Post(() => view.SetMinimapCaveMode(mode));
return r.SetAndReturn(Status.Done,
string.Format(Translations.cmd_minimap_cave_set, mode));
}
private static string BoolStr(bool v) => v ? "ON" : "OFF";
}
}

View file

@ -0,0 +1,98 @@
using System.Text;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
namespace MinecraftClient.Commands
{
public class RecipeBook : Command
{
public override string CmdName => "recipebook";
public override string CmdUsage => "recipebook <list|craft|craftall> [recipe id]";
public override string CmdDesc => Translations.cmd_recipebook_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("craft")
.Executes(r => GetUsage(r.Source, "craft")))
.Then(l => l.Literal("craftall")
.Executes(r => GetUsage(r.Source, "craftall")))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Then(l => l.Literal("list")
.Executes(r => ListRecipes(r.Source)))
.Then(l => l.Literal("craft")
.Then(l => l.Argument("RecipeId", Arguments.String())
.Executes(r => CraftRecipe(r.Source, Arguments.GetString(r, "RecipeId"), makeAll: false))))
.Then(l => l.Literal("craftall")
.Then(l => l.Argument("RecipeId", Arguments.String())
.Executes(r => CraftRecipe(r.Source, Arguments.GetString(r, "RecipeId"), makeAll: 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 // @formatter:off
"list" => GetCmdDescTranslated(),
"craft" => GetCmdDescTranslated(),
"craftall" => GetCmdDescTranslated(),
_ => GetCmdDescTranslated(),
#pragma warning restore format // @formatter:on
});
}
private int ListRecipes(CmdResult r)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetInventoryEnabled())
return r.SetAndReturn(CmdResult.Status.FailNeedInventory);
RecipeBookRecipeEntry[] recipes = handler.GetUnlockedRecipes();
if (recipes.Length == 0)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_recipes);
StringBuilder response = new();
response.AppendLine(Translations.cmd_recipebook_list);
foreach (RecipeBookRecipeEntry recipe in recipes)
response.AppendLine("- " + recipe.DisplayText);
handler.Log.Info(response.ToString().TrimEnd());
return r.SetAndReturn(CmdResult.Status.Done);
}
private int CraftRecipe(CmdResult r, string recipeId, bool makeAll)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetInventoryEnabled())
return r.SetAndReturn(CmdResult.Status.FailNeedInventory);
if (string.IsNullOrWhiteSpace(recipeId))
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_recipe_id_empty);
if (handler.GetProtocolVersion() < Protocol.Handlers.Protocol18Handler.MC_1_13_Version)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_unsupported);
if (handler.GetActiveRecipeBookInventory() is null)
return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_active_inventory);
string normalizedRecipeId = McClient.NormalizeRecipeArgument(recipeId, handler.GetProtocolVersion());
string successMessage = string.Format(makeAll ? Translations.cmd_recipebook_craftall_sent : Translations.cmd_recipebook_craft_sent, normalizedRecipeId);
return handler.SendPlaceRecipe(recipeId, makeAll)
? r.SetAndReturn(CmdResult.Status.Done, successMessage)
: r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_recipebook_craft_failed, normalizedRecipeId));
}
}
}

View file

@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Mapping;
namespace MinecraftClient.Commands
{
public class Teams : Command
{
public override string CmdName => "teams";
public override string CmdUsage => "teams";
public override string CmdDesc => Translations.cmd_teams_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))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => DoListTeams(r.Source))
.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 // @formatter:off
_ => GetCmdDescTranslated(),
#pragma warning restore format // @formatter:on
});
}
private static int DoListTeams(CmdResult r)
{
McClient handler = CmdResult.currentHandler!;
Dictionary<string, PlayerTeam> snapshot = handler.GetTeams();
if (snapshot.Count == 0)
return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_teams_no_teams);
var sb = new StringBuilder();
foreach (var team in snapshot.Values.OrderBy(static t => t.Name, StringComparer.Ordinal))
{
sb.AppendLine(string.Format(Translations.cmd_teams_team_header,
team.Name,
team.DisplayName,
team.Color,
team.Prefix,
team.Suffix,
team.NameTagVisibility,
team.CollisionRule,
team.AllowFriendlyFire,
team.SeeFriendlyInvisibles));
if (team.Members.Count == 0)
sb.AppendLine(Translations.cmd_teams_team_no_members);
else
sb.AppendLine(string.Format(Translations.cmd_teams_team_members,
team.Members.Count,
string.Join(", ", team.Members.OrderBy(static m => m, StringComparer.OrdinalIgnoreCase))));
}
return r.SetAndReturn(CmdResult.Status.Done, sb.ToString().TrimEnd());
}
}
}

View file

@ -0,0 +1,63 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using static MinecraftClient.Settings.ConsoleConfigHealper.ConsoleConfig;
namespace MinecraftClient.Commands
{
public class Tryout : Command
{
public override string CmdName => "tryout";
public override string CmdUsage => "tryout [list|tui]";
public override string CmdDesc => Translations.cmd_tryout_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
{
dispatcher.Register(l => l.Literal("help")
.Then(l => l.Literal(CmdName)
.Executes(r => GetUsage(r.Source))
)
);
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => ListTryouts(r.Source))
.Then(l => l.Literal("list")
.Executes(r => ListTryouts(r.Source)))
.Then(l => l.Literal("tui")
.Executes(r => EnableTuiMode(r.Source)))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
);
}
private int GetUsage(CmdResult r)
{
return r.SetAndReturn(GetCmdDescTranslated());
}
private int ListTryouts(CmdResult r)
{
return r.SetAndReturn(string.Join('\n',
GetCmdDescTranslated(),
string.Empty,
Translations.cmd_tryout_list_header,
$" - {Translations.cmd_tryout_list_tui}"));
}
private int EnableTuiMode(CmdResult r)
{
var previousMode = Settings.Config.Console.General.ConsoleMode;
if (previousMode == ConsoleModeType.tui)
{
return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_tryout_tui_already_enabled);
}
Settings.Config.Console.General.ConsoleMode = ConsoleModeType.tui;
Program.WriteBackSettings();
return r.SetAndReturn(CmdResult.Status.Done,
string.Format(Translations.cmd_tryout_tui_enabled, previousMode, ConsoleModeType.tui));
}
}
}

View file

@ -1,6 +1,7 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
using static MinecraftClient.CommandHandler.CmdResult;
@ -9,7 +10,7 @@ namespace MinecraftClient.Commands
class Useblock : Command
{
public override string CmdName { get { return "useblock"; } }
public override string CmdUsage { get { return "useblock <x> <y> <z>"; } }
public override string CmdUsage { get { return "useblock <x> <y> <z> [mainhand|offhand]"; } }
public override string CmdDesc { get { return Translations.cmd_useblock_desc; } }
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
@ -22,7 +23,11 @@ namespace MinecraftClient.Commands
dispatcher.Register(l => l.Literal(CmdName)
.Then(l => l.Argument("Location", MccArguments.Location())
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"))))
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"), Hand.MainHand))
.Then(l => l.Literal("mainhand")
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"), Hand.MainHand)))
.Then(l => l.Literal("offhand")
.Executes(r => UseBlockAtLocation(r.Source, MccArguments.GetLocation(r, "Location"), Hand.OffHand))))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source, string.Empty))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
@ -39,7 +44,7 @@ namespace MinecraftClient.Commands
});
}
private int UseBlockAtLocation(CmdResult r, Location block)
private int UseBlockAtLocation(CmdResult r, Location block, Hand hand)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetTerrainEnabled())
@ -48,7 +53,7 @@ namespace MinecraftClient.Commands
Location current = handler.GetCurrentLocation();
block = block.ToAbsolute(current).ToFloor();
Location blockCenter = block.ToCenter();
bool res = handler.PlaceBlock(block, Direction.Down, lookAtBlock: true);
bool res = handler.PlaceBlock(block, Direction.Down, hand, lookAtBlock: true);
return r.SetAndReturn(string.Format(Translations.cmd_useblock_use, blockCenter.X, blockCenter.Y, blockCenter.Z, res ? "succeeded" : "failed"), res);
}
}

View file

@ -104,7 +104,7 @@ namespace MinecraftClient
/// </summary>
public static void WriteLine(string line)
{
if (BasicIO)
if (BasicIO || Backend is null)
Console.WriteLine(line);
else
Backend.WriteLine(line);
@ -137,7 +137,7 @@ namespace MinecraftClient
{
str = str.Replace('\n', ' ');
}
if (BasicIO)
if (BasicIO || Backend is null)
{
if (BasicIO_NoColor)
{

View file

@ -1,3 +1,4 @@
using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -23,10 +24,46 @@ public static class Json
public static JsonNode? ParseJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
ReadOnlySpan<char> text = json.AsSpan().TrimStart();
if (!LooksLikeJson(text))
return JsonValue.Create(json);
try { return JsonNode.Parse(json); }
catch (JsonException) { return JsonValue.Create(json); }
}
private static bool LooksLikeJson(ReadOnlySpan<char> text)
{
if (text.IsEmpty)
return false;
return text[0] switch
{
'{' or '"' => true,
'[' => LooksLikeJsonArray(text[1..]),
'-' => text.Length > 1 && char.IsAsciiDigit(text[1]),
>= '0' and <= '9' => true,
't' or 'f' or 'n' => true,
_ => false
};
}
private static bool LooksLikeJsonArray(ReadOnlySpan<char> text)
{
text = text.TrimStart();
if (text.IsEmpty)
return false;
return text[0] switch
{
']' or '{' or '[' or '"' => true,
'-' => text.Length > 1 && char.IsAsciiDigit(text[1]),
>= '0' and <= '9' => true,
't' or 'f' or 'n' => true,
_ => false
};
}
/// <summary>
/// Escape a string for embedding inside a JSON string literal.
/// Uses System.Text.Json serialization and strips the surrounding quotes.
@ -52,4 +89,4 @@ public static class JsonNodeExtensions
JsonValue val when val.TryGetValue<string>(out var s) => s,
_ => node.ToJsonString()
};
}
}

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);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,572 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MinecraftClient.Inventory;
using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_21_5;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components.Subcomponents._1_20_6;
namespace MinecraftClient.Mapping
{
/// <summary>
/// Computes dig duration in ticks for survival-style block breaking.
/// Version-aware across 1.8-1.21.11+, using tool speed, enchantments, effects, and attributes.
/// </summary>
public static class MiningCalculator
{
/// <summary>
/// Compute the number of ticks required to break a block in survival mode.
/// Returns 0 for instant-break blocks, -1 for unbreakable blocks.
/// </summary>
/// <param name="blockMaterial">The block material to break</param>
/// <param name="heldItem">The item in the player's main hand (null for empty hand)</param>
/// <param name="helmetItem">The item in the player's helmet slot (null if empty, used for Aqua Affinity)</param>
/// <param name="effects">Currently active player effects</param>
/// <param name="playerAttributes">Cached player attribute values (from OnEntityProperties)</param>
/// <param name="isUnderwater">Whether the player's eyes are submerged in water</param>
/// <param name="isOnGround">Whether the player is on the ground</param>
/// <param name="protocolVersion">The Minecraft protocol version</param>
/// <returns>Ticks to break the block, 0 for instant, -1 for unbreakable</returns>
public static int ComputeDigTicks(
Material blockMaterial,
Item? heldItem,
Item? helmetItem,
Dictionary<Effects, EffectData> effects,
Dictionary<string, double> playerAttributes,
bool isUnderwater,
bool isOnGround,
int protocolVersion)
{
float hardness = BlockHardness.GetHardness(blockMaterial);
if (hardness < 0)
return -1; // Unbreakable
if (hardness == 0)
return 0; // Instant break
float destroySpeed = GetDestroySpeed(
blockMaterial, heldItem, helmetItem, effects, playerAttributes,
isUnderwater, isOnGround, protocolVersion);
bool correctTool = HasCorrectToolForDrops(blockMaterial, heldItem, protocolVersion);
int divisor = correctTool ? 30 : 100;
float destroyProgress = destroySpeed / hardness / divisor;
if (destroyProgress >= 1.0f)
return 0; // Instant break
return (int)MathF.Ceiling(1.0f / destroyProgress);
}
/// <summary>
/// Compute the player's destroy speed for a given block, following vanilla formulas.
/// </summary>
private static float GetDestroySpeed(
Material blockMaterial,
Item? heldItem,
Item? helmetItem,
Dictionary<Effects, EffectData> effects,
Dictionary<string, double> playerAttributes,
bool isUnderwater,
bool isOnGround,
int protocolVersion)
{
float speed = GetToolSpeed(blockMaterial, heldItem, protocolVersion);
if (speed > 1.0f)
{
speed += GetEfficiencyBonus(heldItem, playerAttributes, protocolVersion);
}
int digSpeedAmplifier = GetDigSpeedAmplifier(effects);
if (digSpeedAmplifier >= 0)
speed *= 1.0f + (digSpeedAmplifier + 1) * 0.2f;
// Mining Fatigue
if (effects.TryGetValue(Effects.MiningFatigue, out var fatigueData))
{
float multiplier = fatigueData.Amplifier switch
{
0 => 0.3f,
1 => 0.09f,
2 => 0.0027f,
_ => 8.1E-4f
};
speed *= multiplier;
}
// Attribute multipliers for modern versions
if (protocolVersion >= Protocol18Handler.MC_1_20_6_Version)
{
// BLOCK_BREAK_SPEED attribute (default 1.0)
if (playerAttributes.TryGetValue("player.block_break_speed", out double bbs))
speed *= (float)bbs;
}
// Underwater penalty
if (isUnderwater)
{
if (protocolVersion >= Protocol18Handler.MC_1_21_11_Version)
{
// 1.21.11+: Uses SUBMERGED_MINING_SPEED attribute (default 0.2)
double submergedSpeed = 0.2;
if (playerAttributes.TryGetValue("player.submerged_mining_speed", out double sms))
submergedSpeed = sms;
speed *= (float)submergedSpeed;
}
else
{
// Pre-1.21.11: /5 unless Aqua Affinity
bool hasAquaAffinity = GetEnchantmentLevel(helmetItem, Enchantments.AquaAffinity, protocolVersion) > 0;
if (!hasAquaAffinity)
speed /= 5.0f;
}
}
// Airborne penalty
if (!isOnGround)
speed /= 5.0f;
return speed;
}
/// <summary>
/// Get the base tool mining speed for a block.
/// For 1.20.6+ with ToolComponent, uses structured component data.
/// For older versions, uses hardcoded tool speed tables.
/// </summary>
private static float GetToolSpeed(Material blockMaterial, Item? heldItem, int protocolVersion)
{
if (heldItem is null)
return 1.0f;
// Modern path: use ToolComponent from structured components
if (protocolVersion >= Protocol18Handler.MC_1_20_6_Version
&& TryGetToolRules(heldItem, out List<RuleSubComponent>? rules, out float defaultMiningSpeed))
{
foreach (var rule in rules)
{
if (rule.HasSpeed && MatchesBlockSet(rule.Blocks, blockMaterial))
return rule.Speed;
}
// Structured tool data covers modern mining rules, but keep the legacy fallback for
// explicit block holder-sets that MCC cannot resolve yet (for example cobweb).
if (defaultMiningSpeed > 1.0f)
return defaultMiningSpeed;
}
// Legacy path: hardcoded tool speed tables
return GetLegacyToolSpeed(heldItem.Type, blockMaterial);
}
/// <summary>
/// Check whether the tool provides correct drops for a block.
/// </summary>
private static bool HasCorrectToolForDrops(Material blockMaterial, Item? heldItem, int protocolVersion)
{
if (!BlockHardness.RequiresCorrectTool(blockMaterial))
return true;
if (heldItem is null)
return false;
// Modern path: check ToolComponent rules
if (protocolVersion >= Protocol18Handler.MC_1_20_6_Version
&& TryGetToolRules(heldItem, out List<RuleSubComponent>? rules, out _))
{
foreach (var rule in rules)
{
if (rule.HasCorrectDropForBlocks && MatchesBlockSet(rule.Blocks, blockMaterial))
return rule.CorrectDropForBlocks;
}
}
// Legacy path, plus a modern fallback for direct block holder-sets MCC cannot resolve yet.
return IsCorrectToolLegacy(heldItem.Type, blockMaterial);
}
private static bool TryGetToolRules(
Item heldItem,
[NotNullWhen(true)] out List<RuleSubComponent>? rules,
out float defaultMiningSpeed)
{
rules = null;
defaultMiningSpeed = 1.0f;
if (heldItem.Components is null)
return false;
if (heldItem.Components.OfType<ToolComponent>().FirstOrDefault() is ToolComponent toolComponent)
{
rules = toolComponent.Rules;
defaultMiningSpeed = toolComponent.DefaultMiningSpeed;
return true;
}
if (heldItem.Components.OfType<ToolComponent1215>().FirstOrDefault() is ToolComponent1215 toolComponent1215)
{
rules = toolComponent1215.Rules;
defaultMiningSpeed = toolComponent1215.DefaultMiningSpeed;
return true;
}
return false;
}
/// <summary>
/// Match a block material against a ToolComponent BlockSetSubcomponent.
/// </summary>
private static bool MatchesBlockSet(
Protocol.Handlers.StructuredComponents.Components.Subcomponents._1_20_6.BlockSetSubcomponent blockSet,
Material blockMaterial)
{
if (blockSet.BlockIds is not null)
{
// Check against explicit block state IDs
foreach (int blockId in blockSet.BlockIds)
{
if (Block.Palette.FromId(blockId) == blockMaterial)
return true;
}
}
if (blockSet.TagName is not null)
{
// Match against tag name (e.g., "minecraft:mineable/pickaxe")
return MatchesBlockTag(blockSet.TagName, blockMaterial);
}
return false;
}
/// <summary>
/// Approximate block tag matching using Material2Tool categories.
/// Tags like "minecraft:mineable/pickaxe" map to the appropriate tool categories.
/// </summary>
private static bool MatchesBlockTag(string tagName, Material blockMaterial)
{
// Normalize tag name
string tag = tagName.Replace("minecraft:", "");
ItemType[] tools = Material2Tool.GetCorrectToolForBlock(blockMaterial);
return tag switch
{
"mineable/pickaxe" => tools.Length > 0 && IsPickaxe(tools[0]),
"mineable/axe" => tools.Length > 0 && IsAxe(tools[0]),
"mineable/shovel" => tools.Length > 0 && IsShovel(tools[0]),
"mineable/hoe" => tools.Length > 0 && IsHoe(tools[0]),
"leaves" => IsLeaf(blockMaterial),
"wool" => IsWool(blockMaterial),
"incorrect_for_wooden_tool" => RequiresHigherTier(blockMaterial, 0),
"incorrect_for_gold_tool" => RequiresHigherTier(blockMaterial, 0),
"incorrect_for_stone_tool" => RequiresHigherTier(blockMaterial, 1),
"incorrect_for_copper_tool" => RequiresHigherTier(blockMaterial, 1),
"incorrect_for_iron_tool" => RequiresHigherTier(blockMaterial, 2),
"incorrect_for_diamond_tool" => RequiresHigherTier(blockMaterial, 3),
"incorrect_for_netherite_tool" => RequiresHigherTier(blockMaterial, 4),
_ => false
};
}
private static bool RequiresHigherTier(Material blockMaterial, int tier)
{
ItemType[] recommended = Material2Tool.GetCorrectToolForBlock(blockMaterial);
if (recommended.Length == 0)
return false;
return GetRequiredTier(blockMaterial, recommended) > tier;
}
/// <summary>
/// Get the enchantment level from an item, supporting both legacy NBT and modern structured components.
/// </summary>
public static int GetEnchantmentLevel(Item? item, Enchantments enchantment, int protocolVersion)
{
if (item is null)
return 0;
// Modern path: structured components (1.20.6+)
var enchList = item.EnchantmentList;
if (enchList is not null)
{
var ench = enchList.FirstOrDefault(e => e.Type == enchantment);
if (ench is not null)
return ench.Level;
}
// Legacy path: NBT data
if (item.NBT is not null &&
item.NBT.TryGetValue("Enchantments", out object? enchantments))
{
try
{
string enchNameLower = GetEnchantmentResourceName(enchantment);
foreach (Dictionary<string, object> enchEntry in (object[])enchantments)
{
string id = ((string)enchEntry["id"]).ToLowerInvariant();
if (id == enchNameLower || id == "minecraft:" + enchNameLower)
return (short)enchEntry["lvl"];
}
}
catch
{
// NBT parsing failure - return 0
}
}
return 0;
}
/// <summary>
/// Map Enchantments enum to Minecraft resource name (e.g., "efficiency").
/// </summary>
private static string GetEnchantmentResourceName(Enchantments enchantment)
{
return enchantment switch
{
Enchantments.AquaAffinity => "aqua_affinity",
Enchantments.BaneOfArthropods => "bane_of_arthropods",
Enchantments.BlastProtection => "blast_protection",
Enchantments.Efficiency => "efficiency",
Enchantments.FeatherFalling => "feather_falling",
Enchantments.FireAspect => "fire_aspect",
Enchantments.FireProtection => "fire_protection",
Enchantments.FrostWalker => "frost_walker",
Enchantments.LuckOfTheSea => "luck_of_the_sea",
Enchantments.ProjectileProtection => "projectile_protection",
Enchantments.QuickCharge => "quick_charge",
Enchantments.SilkTouch => "silk_touch",
Enchantments.SoulSpeed => "soul_speed",
Enchantments.SwiftSneak => "swift_sneak",
Enchantments.VanishingCurse => "vanishing_curse",
Enchantments.BindingCurse => "binding_curse",
Enchantments.WindBurst => "wind_burst",
_ => enchantment.ToString().ToUnderscoreCase()
};
}
#region Legacy Tool Speed Tables
/// <summary>
/// Legacy tool speed for pre-1.20.6 versions using hardcoded values.
/// </summary>
private static float GetLegacyToolSpeed(ItemType toolType, Material blockMaterial)
{
float specialToolSpeed = toolType switch
{
ItemType.Shears => GetShearsSpeed(blockMaterial),
_ when IsSword(toolType) && blockMaterial == Material.Cobweb => 15.0f,
_ => 1.0f
};
if (specialToolSpeed > 1.0f)
return specialToolSpeed;
ItemType[] recommended = Material2Tool.GetCorrectToolForBlock(blockMaterial);
if (recommended.Length == 0)
return 1.0f;
// Check if the held tool matches the recommended tool category
ToolCategory heldCategory = GetToolCategory(toolType);
ToolCategory neededCategory = GetToolCategory(recommended[0]);
if (heldCategory == ToolCategory.None || heldCategory != neededCategory)
return 1.0f;
return GetBaseToolSpeed(toolType);
}
private static float GetBaseToolSpeed(ItemType toolType)
{
return toolType switch
{
// Wooden tools
ItemType.WoodenPickaxe or ItemType.WoodenAxe or ItemType.WoodenShovel or
ItemType.WoodenSword or ItemType.WoodenHoe => 2.0f,
// Stone tools
ItemType.StonePickaxe or ItemType.StoneAxe or ItemType.StoneShovel or
ItemType.StoneSword or ItemType.StoneHoe => 4.0f,
// Iron tools
ItemType.IronPickaxe or ItemType.IronAxe or ItemType.IronShovel or
ItemType.IronSword or ItemType.IronHoe => 6.0f,
// Diamond tools
ItemType.DiamondPickaxe or ItemType.DiamondAxe or ItemType.DiamondShovel or
ItemType.DiamondSword or ItemType.DiamondHoe => 8.0f,
// Netherite tools
ItemType.NetheritePickaxe or ItemType.NetheriteAxe or ItemType.NetheriteShovel or
ItemType.NetheriteSword or ItemType.NetheriteHoe => 9.0f,
// Golden tools
ItemType.GoldenPickaxe or ItemType.GoldenAxe or ItemType.GoldenShovel or
ItemType.GoldenSword or ItemType.GoldenHoe => 12.0f,
// Shears
ItemType.Shears => 2.0f,
_ => 1.0f
};
}
/// <summary>
/// Check if the held tool is the correct tool for drops in legacy versions.
/// Uses Material2Tool's recommendations to determine correctness.
/// </summary>
private static bool IsCorrectToolLegacy(ItemType toolType, Material blockMaterial)
{
ItemType[] recommended = Material2Tool.GetCorrectToolForBlock(blockMaterial);
if (recommended.Length == 0)
return false;
ToolCategory heldCategory = GetToolCategory(toolType);
ToolCategory neededCategory = GetToolCategory(recommended[0]);
if (heldCategory == ToolCategory.None || heldCategory != neededCategory)
{
if (toolType == ItemType.Shears && blockMaterial == Material.Cobweb)
return true;
if (IsSword(toolType) && blockMaterial == Material.Cobweb)
return true;
return false;
}
// Check tool tier requirement
int heldTier = GetToolTier(toolType);
int requiredTier = GetRequiredTier(blockMaterial, recommended);
return heldTier >= requiredTier;
}
/// <summary>
/// Get the minimum tool tier required for a block based on Material2Tool's recommendation ordering.
/// </summary>
private static int GetRequiredTier(Material blockMaterial, ItemType[] recommended)
{
if (recommended.Length == 0)
return 0;
// Material2Tool lists tools from highest to lowest tier.
// The last tool in the array is the minimum required tier.
return GetToolTier(recommended[^1]);
}
private enum ToolCategory
{
None,
Pickaxe,
Axe,
Shovel,
Hoe,
Sword,
Shears
}
private static ToolCategory GetToolCategory(ItemType item)
{
if (IsPickaxe(item)) return ToolCategory.Pickaxe;
if (IsAxe(item)) return ToolCategory.Axe;
if (IsShovel(item)) return ToolCategory.Shovel;
if (IsHoe(item)) return ToolCategory.Hoe;
if (IsSword(item)) return ToolCategory.Sword;
if (item == ItemType.Shears) return ToolCategory.Shears;
return ToolCategory.None;
}
private static int GetToolTier(ItemType item)
{
string name = item.ToString();
if (name.StartsWith("Wooden")) return 0;
if (name.StartsWith("Golden")) return 0;
if (name.StartsWith("Stone")) return 1;
if (name.StartsWith("Iron")) return 2;
if (name.StartsWith("Diamond")) return 3;
if (name.StartsWith("Netherite")) return 4;
return 0;
}
private static bool IsPickaxe(ItemType item) =>
item is ItemType.WoodenPickaxe or ItemType.StonePickaxe or ItemType.IronPickaxe
or ItemType.GoldenPickaxe or ItemType.DiamondPickaxe or ItemType.NetheritePickaxe;
private static bool IsAxe(ItemType item) =>
item is ItemType.WoodenAxe or ItemType.StoneAxe or ItemType.IronAxe
or ItemType.GoldenAxe or ItemType.DiamondAxe or ItemType.NetheriteAxe;
private static bool IsShovel(ItemType item) =>
item is ItemType.WoodenShovel or ItemType.StoneShovel or ItemType.IronShovel
or ItemType.GoldenShovel or ItemType.DiamondShovel or ItemType.NetheriteShovel;
private static bool IsHoe(ItemType item) =>
item is ItemType.WoodenHoe or ItemType.StoneHoe or ItemType.IronHoe
or ItemType.GoldenHoe or ItemType.DiamondHoe or ItemType.NetheriteHoe;
private static bool IsSword(ItemType item) =>
item is ItemType.WoodenSword or ItemType.StoneSword or ItemType.IronSword
or ItemType.GoldenSword or ItemType.DiamondSword or ItemType.NetheriteSword;
private static float GetShearsSpeed(Material block)
{
return block switch
{
Material.Cobweb => 15.0f,
Material.Vine or Material.GlowLichen => 2.0f,
_ when IsLeaf(block) => 15.0f,
_ when IsWool(block) => 5.0f,
_ => 1.0f
};
}
private static bool IsShearable(Material block) =>
block == Material.Cobweb || IsLeaf(block) || IsWool(block) || block is Material.Vine or Material.GlowLichen;
private static bool IsLeaf(Material block) =>
block is Material.OakLeaves or Material.SpruceLeaves or Material.BirchLeaves
or Material.JungleLeaves or Material.AcaciaLeaves or Material.DarkOakLeaves
or Material.CherryLeaves or Material.MangroveLeaves or Material.AzaleaLeaves
or Material.FloweringAzaleaLeaves or Material.PaleOakLeaves;
private static bool IsWool(Material block) =>
block is Material.WhiteWool or Material.OrangeWool or Material.MagentaWool
or Material.LightBlueWool or Material.YellowWool or Material.LimeWool
or Material.PinkWool or Material.GrayWool or Material.LightGrayWool
or Material.CyanWool or Material.PurpleWool or Material.BlueWool
or Material.BrownWool or Material.GreenWool or Material.RedWool
or Material.BlackWool;
private static float GetEfficiencyBonus(Item? heldItem, Dictionary<string, double> playerAttributes, int protocolVersion)
{
if (protocolVersion >= Protocol18Handler.MC_1_21_11_Version
&& playerAttributes.TryGetValue("player.mining_efficiency", out double miningEfficiency)
&& miningEfficiency > 0.0)
{
return (float)miningEfficiency;
}
int efficiencyLevel = GetEnchantmentLevel(heldItem, Enchantments.Efficiency, protocolVersion);
return efficiencyLevel > 0 ? efficiencyLevel * efficiencyLevel + 1 : 0.0f;
}
private static int GetDigSpeedAmplifier(Dictionary<Effects, EffectData> effects)
{
int amplifier = -1;
if (effects.TryGetValue(Effects.Haste, out var hasteData))
amplifier = Math.Max(amplifier, hasteData.Amplifier);
if (effects.TryGetValue(Effects.ConduitPower, out var conduitData))
amplifier = Math.Max(amplifier, conduitData.Amplifier);
return amplifier;
}
#endregion
}
}

View file

@ -232,8 +232,8 @@ namespace MinecraftClient.Mapping
int tentativeGScore = current.GScore + (int)current.Location.DistanceSquared(neighbor);
// If the neighbor is not in the GScoreDict OR its current tentativeGScore is lower than the previously saved one:
if (!gScoreDict.ContainsKey(neighbor) ||
(gScoreDict.ContainsKey(neighbor) && tentativeGScore < gScoreDict[neighbor]))
if (!gScoreDict.TryGetValue(neighbor, out int existingGScore) ||
tentativeGScore < existingGScore)
{
// Save the new relation between the neighbored block and the current one
cameFrom[neighbor] = current.Location;

View file

@ -0,0 +1,49 @@
using System.Collections.Generic;
namespace MinecraftClient.Mapping
{
/// <summary>
/// Represents a Minecraft scoreboard team and its current state.
/// </summary>
public class PlayerTeam
{
/// <summary>Team internal name (up to 16 chars)</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Display name component (formatted text)</summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>Friendly fire is allowed between team members</summary>
public bool AllowFriendlyFire { get; set; }
/// <summary>Team members can see invisible teammates</summary>
public bool SeeFriendlyInvisibles { get; set; }
/// <summary>
/// Nametag visibility rule.
/// Values: "always", "hideForOtherTeams", "hideForOwnTeam", "never"
/// </summary>
public string NameTagVisibility { get; set; } = string.Empty;
/// <summary>
/// Collision rule.
/// Values: "always", "pushOtherTeams", "pushOwnTeam", "never"
/// </summary>
public string CollisionRule { get; set; } = string.Empty;
/// <summary>
/// Team color as ChatFormatting enum ordinal (-1 = RESET/none,
/// 015 = BLACK … WHITE).
/// </summary>
public int Color { get; set; } = -1;
/// <summary>Prefix displayed before member names (formatted text)</summary>
public string Prefix { get; set; } = string.Empty;
/// <summary>Suffix displayed after member names (formatted text)</summary>
public string Suffix { get; set; } = string.Empty;
/// <summary>Current set of player / entity names on this team</summary>
public HashSet<string> Members { get; } = new(System.StringComparer.OrdinalIgnoreCase);
}
}

View file

@ -12,9 +12,9 @@ namespace MinecraftClient.Mapping
{
/// <summary>
/// The chunks contained into the Minecraft world
/// Tuple<int, int>: Tuple<chunkX, chunkZ>
/// (int ChunkX, int ChunkZ): chunkX, chunkZ
/// </summary>
private ConcurrentDictionary<Tuple<int, int>, ChunkColumn> chunks = new();
private ConcurrentDictionary<(int ChunkX, int ChunkZ), ChunkColumn> chunks = new();
/// <summary>
/// The dimension info of the world
@ -49,12 +49,12 @@ namespace MinecraftClient.Mapping
{
get
{
chunks.TryGetValue(new(chunkX, chunkZ), out ChunkColumn? chunkColumn);
chunks.TryGetValue((chunkX, chunkZ), out ChunkColumn? chunkColumn);
return chunkColumn;
}
set
{
Tuple<int, int> chunkCoord = new(chunkX, chunkZ);
var chunkCoord = (chunkX, chunkZ);
if (value is null)
chunks.TryRemove(chunkCoord, out _);
else
@ -361,7 +361,7 @@ namespace MinecraftClient.Mapping
/// <param name="loadCompleted">Whether the ChunkColumn has been fully loaded</param>
public void StoreChunk(int chunkX, int chunkY, int chunkZ, int chunkColumnSize, Chunk? chunk, bool loadCompleted)
{
ChunkColumn chunkColumn = chunks.GetOrAdd(new(chunkX, chunkZ), (_) => new(chunkColumnSize));
ChunkColumn chunkColumn = chunks.GetOrAdd((chunkX, chunkZ), (_) => new(chunkColumnSize));
chunkColumn[chunkY] = chunk;
if (loadCompleted)
chunkColumn.FullyLoaded = true;

View file

@ -44,10 +44,15 @@ namespace MinecraftClient
private readonly Queue<Action> threadTasks = new();
private readonly Lock threadTasksLock = new();
private readonly Lock recipeBookLock = new();
private readonly Lock achievementsLock = new();
private readonly List<ChatBot> bots = new();
private static readonly List<ChatBot> botsOnHold = new();
private static readonly Dictionary<int, Container> inventories = new();
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 List<string> registeredServerPluginChannels = new();
@ -105,6 +110,12 @@ namespace MinecraftClient
// player effects
private readonly Dictionary<Effects, EffectData> playerEffects = new();
// player attributes (e.g., block_break_speed, mining_efficiency, submerged_mining_speed)
private readonly Dictionary<string, double> playerAttributes = new();
// scoreboard teams (key = team name)
private readonly Dictionary<string, PlayerTeam> teams = new(StringComparer.Ordinal);
// Sneaking
public bool IsSneaking { get; set; } = false;
@ -156,6 +167,30 @@ namespace MinecraftClient
return new Dictionary<Effects, EffectData>(playerEffects);
}
/// <summary>
/// Get a snapshot of all known scoreboard teams.
/// </summary>
/// <returns>Dictionary mapping team name to <see cref="PlayerTeam"/></returns>
public Dictionary<string, PlayerTeam> GetTeams()
{
lock (teams)
return new Dictionary<string, PlayerTeam>(teams, StringComparer.Ordinal);
}
/// <summary>
/// Get the team that contains the given player/entity name, or <c>null</c> if not found.
/// </summary>
public PlayerTeam? GetPlayerTeam(string playerName)
{
lock (teams)
{
foreach (var team in teams.Values)
if (team.Members.Contains(playerName))
return team;
return null;
}
}
public int GetLevel() { return playerLevel; }
public int GetTotalExperience() { return playerTotalExperience; }
public byte GetCurrentSlot() { return CurrentSlot; }
@ -1257,6 +1292,7 @@ namespace MinecraftClient
inventoryHandlingEnabled = false;
inventoryHandlingRequested = false;
inventories.Clear();
ClearUnlockedRecipes();
}
return true;
}
@ -1358,6 +1394,54 @@ namespace MinecraftClient
return lastEnchantment;
}
/// <summary>
/// Get all unlocked recipe book recipe identifiers.
/// </summary>
/// <returns>Unlocked recipe identifiers sorted alphabetically</returns>
public RecipeBookRecipeEntry[] GetUnlockedRecipes()
{
lock (recipeBookLock)
{
return unlockedRecipes.Values.OrderBy(static recipe => recipe.CommandId, StringComparer.Ordinal).ToArray();
}
}
/// <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>
/// Get all Entities
/// </summary>
@ -1404,6 +1488,22 @@ namespace MinecraftClient
return GetInventory(0)!;
}
/// <summary>
/// Get the currently active inventory if it supports recipe book crafting.
/// </summary>
/// <returns>Active recipe book inventory, or null if the active inventory does not support recipe book crafting</returns>
public Container? GetActiveRecipeBookInventory()
{
if (InvokeRequired)
return InvokeOnMainThread(() => GetActiveRecipeBookInventory());
if (inventories.Count == 0)
return null;
Container activeInventory = inventories.MaxBy(static pair => pair.Key).Value;
return SupportsRecipeBook(activeInventory.Type) ? activeInventory : null;
}
/// <summary>
/// Get a set of online player names
/// </summary>
@ -1499,6 +1599,12 @@ namespace MinecraftClient
if (String.IsNullOrEmpty(text))
return;
if (!CanSendMessage)
{
Log.Warn(Translations.mcc_send_text_not_connected);
return;
}
int maxLength = handler.GetMaxChatMessageLength();
lock (chatQueue)
@ -2516,6 +2622,7 @@ namespace MinecraftClient
inventories.Clear();
inventories[0] = new Container(0, ContainerType.PlayerInventory, "Player Inventory");
ClearUnlockedRecipes();
return true;
}
@ -2599,6 +2706,13 @@ namespace MinecraftClient
if (lookAtBlock)
UpdateLocation(GetCurrentLocation(), location);
// Auto-compute dig duration for survival/adventure mode when not explicitly supplied
if (duration <= 0 && protocolversion >= Protocol18Handler.MC_1_8_Version
&& gamemode is 0 or 2) // Survival or Adventure
{
duration = ComputeAutoDigDuration(location);
}
// Send dig start and dig end, will need to wait for server response to know dig result
// See https://wiki.vg/How_to_Write_a_Client#Digging for more details
bool result = handler.SendPlayerDigging(0, location, blockFace, sequenceId++)
@ -2616,6 +2730,52 @@ namespace MinecraftClient
}
}
/// <summary>
/// Compute the automatic dig duration in seconds for a block, based on held tool,
/// enchantments, effects, attributes, and player state.
/// Returns 0 for instant-break blocks.
/// </summary>
private double ComputeAutoDigDuration(Location location)
{
try
{
Block block = world.GetBlock(location);
Material blockMaterial = block.Type;
if (blockMaterial == Material.Air)
return 0;
// Get held item from player inventory
Item? heldItem = null;
Item? helmetItem = null;
if (inventories.TryGetValue(0, out var playerInv))
{
int hotbarSlot = 36 + CurrentSlot; // Hotbar slots are 36-44
playerInv.Items.TryGetValue(hotbarSlot, out heldItem);
playerInv.Items.TryGetValue(5, out helmetItem); // Slot 5 = helmet
}
int ticks = MiningCalculator.ComputeDigTicks(
blockMaterial,
heldItem,
helmetItem,
playerEffects,
playerAttributes,
playerPhysics.InWater,
playerPhysics.OnGround,
protocolversion);
if (ticks <= 0)
return 0;
return (double)ticks / Settings.ClientTicksPerSecond;
}
catch
{
return 0;
}
}
/// <summary>
/// Change active slot in the player inventory
/// </summary>
@ -2752,6 +2912,31 @@ namespace MinecraftClient
return handler.SendRenameItem(itemName);
}
/// <summary>
/// Send a recipe book craft request for the currently active crafting inventory.
/// </summary>
/// <param name="recipeId">Recipe identifier to craft</param>
/// <param name="makeAll">True to craft as many items as possible</param>
/// <returns>True if the packet was sent</returns>
public bool SendPlaceRecipe(string recipeId, bool makeAll)
{
if (InvokeRequired)
return InvokeOnMainThread(() => SendPlaceRecipe(recipeId, makeAll));
if (protocolversion < Protocol18Handler.MC_1_13_Version)
return false;
Container? activeInventory = GetActiveRecipeBookInventory();
if (activeInventory is null)
return false;
string normalizedRecipeId = NormalizeRecipeArgument(recipeId, protocolversion);
if (normalizedRecipeId.Length == 0)
return false;
return handler.SendPlaceRecipe(activeInventory.ID, normalizedRecipeId, makeAll);
}
#endregion
#region Event handlers: An event occurs on the Server
@ -3737,6 +3922,44 @@ namespace MinecraftClient
}
}
/// <summary>
/// Called when an entity velocity update is received.
/// </summary>
/// <param name="entityID">Entity ID</param>
/// <param name="velocityX">Velocity on X axis (blocks/tick)</param>
/// <param name="velocityY">Velocity on Y axis (blocks/tick)</param>
/// <param name="velocityZ">Velocity on Z axis (blocks/tick)</param>
public void OnEntityVelocity(int entityID, double velocityX, double velocityY, double velocityZ)
{
if (entities.TryGetValue(entityID, out Entity? entity))
DispatchBotEvent(bot => bot.OnEntityVelocity(entity, velocityX, velocityY, velocityZ));
}
/// <summary>
/// Called when a sound packet is received.
/// </summary>
/// <param name="soundName">Sound key when available, otherwise null</param>
/// <param name="location">Sound location when available</param>
/// <param name="category">Sound category id from packet</param>
/// <param name="volume">Sound volume</param>
/// <param name="pitch">Sound pitch</param>
/// <param name="entityID">Source entity id for entity sound packets, if any</param>
public void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch,
int? entityID)
{
Entity? sourceEntity = null;
Location? resolvedLocation = location;
if (entityID is int id && entities.TryGetValue(id, out Entity? entity))
{
sourceEntity = entity;
resolvedLocation ??= entity.Location;
}
DispatchBotEvent(bot => bot.OnSoundEffect(soundName, resolvedLocation, category, volume, pitch,
sourceEntity));
}
/// <summary>
/// Called when received entity properties from server.
/// </summary>
@ -3746,6 +3969,9 @@ namespace MinecraftClient
{
if (EntityID == playerEntityID)
{
foreach (var kvp in prop)
playerAttributes[kvp.Key] = kvp.Value;
DispatchBotEvent(bot => bot.OnPlayerProperty(prop));
}
}
@ -3777,11 +4003,15 @@ namespace MinecraftClient
{
DateTime currentTime = DateTime.Now;
long tickDiff = WorldAge - lastAge;
Double tps = tickDiff / (currentTime - lastTime).TotalSeconds;
double tps = tickDiff / (currentTime - lastTime).TotalSeconds;
lastAge = WorldAge;
lastTime = currentTime;
if (tps <= 20 && tps > 0)
if (tps > 0)
{
// A Minecraft server cannot genuinely exceed 20 TPS; values above 20 are
// caused by packet-timing jitter. Clamp instead of discarding so that a
// healthy server averages to 20 rather than being biased downward.
tps = Math.Min(tps, 20.0);
// calculate average tps
if (tpsSamples.Count >= maxSamples)
{
@ -3951,7 +4181,78 @@ namespace MinecraftClient
{
DispatchBotEvent(bot => bot.OnUpdateScore(entityName, action, objectiveName, objectiveDisplayName, objectiveValue, numberFormat));
}
/// <summary>
/// Called when a Teams packet is received. Updates the internal team state and notifies bots.
/// </summary>
public void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags,
string nameTagVisibility, string collisionRule, int color,
string prefix, string suffix, List<string> players)
{
lock (teams)
{
switch (method)
{
case 0: // create
var newTeam = new PlayerTeam
{
Name = teamName,
DisplayName = displayName,
AllowFriendlyFire = (friendlyFlags & 0x01) != 0,
SeeFriendlyInvisibles = (friendlyFlags & 0x02) != 0,
NameTagVisibility = nameTagVisibility,
CollisionRule = collisionRule,
Color = color,
Prefix = prefix,
Suffix = suffix
};
foreach (var p in players)
newTeam.Members.Add(p);
teams[teamName] = newTeam;
break;
case 1: // remove
teams.Remove(teamName);
break;
case 2: // update parameters
if (!teams.TryGetValue(teamName, out var updateTeam))
{
updateTeam = new PlayerTeam { Name = teamName };
teams[teamName] = updateTeam;
}
updateTeam.DisplayName = displayName;
updateTeam.AllowFriendlyFire = (friendlyFlags & 0x01) != 0;
updateTeam.SeeFriendlyInvisibles = (friendlyFlags & 0x02) != 0;
updateTeam.NameTagVisibility = nameTagVisibility;
updateTeam.CollisionRule = collisionRule;
updateTeam.Color = color;
updateTeam.Prefix = prefix;
updateTeam.Suffix = suffix;
break;
case 3: // add players
if (!teams.TryGetValue(teamName, out var addTeam))
{
addTeam = new PlayerTeam { Name = teamName };
teams[teamName] = addTeam;
}
foreach (var p in players)
addTeam.Members.Add(p);
break;
case 4: // remove players
if (teams.TryGetValue(teamName, out var removeTeam))
foreach (var p in players)
removeTeam.Members.Remove(p);
break;
}
}
DispatchBotEvent(bot => bot.OnTeam(teamName, method, displayName, friendlyFlags,
nameTagVisibility, collisionRule, color, prefix, suffix, players));
}
/// <summary>
/// Called when the client received the Tab Header and Footer
/// </summary>
@ -4152,6 +4453,95 @@ namespace MinecraftClient
Log.Debug("CanSendMessage = " + canSendMessage);
}
public void OnRecipeBookAdd(RecipeBookRecipeEntry[] recipes, bool replace)
{
lock (recipeBookLock)
{
if (replace)
unlockedRecipes.Clear();
foreach (RecipeBookRecipeEntry recipe in recipes)
{
// Guard against malformed server packets that send empty display IDs.
if (!string.IsNullOrWhiteSpace(recipe.CommandId))
unlockedRecipes[recipe.CommandId] = recipe;
}
}
}
public void OnRecipeBookRemove(string[] recipeIds)
{
lock (recipeBookLock)
{
foreach (string recipeId in recipeIds)
{
if (!string.IsNullOrWhiteSpace(recipeId))
unlockedRecipes.Remove(recipeId);
}
}
}
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>
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom
@ -4296,6 +4686,51 @@ namespace MinecraftClient
return ((int)blockLocation.X, (int)blockLocation.Y, (int)blockLocation.Z);
}
private static bool SupportsRecipeBook(ContainerType containerType)
{
return containerType switch
{
ContainerType.PlayerInventory or
ContainerType.Crafting or
ContainerType.Furnace or
ContainerType.BlastFurnace or
ContainerType.Smoker or
ContainerType.Stonecutter => true,
_ => false,
};
}
private void ClearUnlockedRecipes()
{
lock (recipeBookLock)
{
unlockedRecipes.Clear();
}
}
/// <summary>
/// Normalize a recipe argument for the target protocol version.
/// Legacy recipe-book packets use identifiers and default to the minecraft namespace.
/// 1.21.2+ recipe-book packets use numeric recipe display ids and should be left trimmed-only.
/// </summary>
internal static string NormalizeRecipeArgument(string recipeId, int protocolVersion)
{
return protocolVersion >= Protocol18Handler.MC_1_21_2_Version
? recipeId.Trim()
: NormalizeRecipeId(recipeId);
}
private static string NormalizeRecipeId(string recipeId)
{
string trimmedRecipeId = recipeId.Trim();
if (trimmedRecipeId.Length == 0)
return string.Empty;
return trimmedRecipeId.Contains(':', StringComparison.Ordinal)
? trimmedRecipeId
: "minecraft:" + trimmedRecipeId;
}
#endregion
}
}

View file

@ -21,6 +21,8 @@
<ItemGroup>
<EmbeddedResource Include="Physics\BlockShapeData.json" LogicalName="BlockShapeData.json" />
<EmbeddedResource Include="Mcp\Prompts\MccMcpOperatorPrompt.md" LogicalName="MccMcpOperatorPrompt.md" />
<EmbeddedResource Include="Tui\MinimapBlockColors.json" LogicalName="MinimapBlockColors.json" />
<EmbeddedResource Include="Tui\MinimapEntityCategories.json" LogicalName="MinimapEntityCategories.json" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Protocol\Handlers\Compression\**" />

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -19,7 +20,7 @@ namespace MinecraftClient.Physics
private static readonly Aabb[] FullBlockArray = { FullBlock };
private static readonly Aabb[] EmptyArray = Array.Empty<Aabb>();
private static Dictionary<int, Aabb[]>? stateToShape;
private static FrozenDictionary<int, Aabb[]>? stateToShape;
private static Dictionary<string, object>? prismarineBlocks;
private static Dictionary<int, Aabb[]>? prismarineShapes;
@ -136,14 +137,21 @@ namespace MinecraftClient.Physics
private static void BuildStateMap()
{
stateToShape = new Dictionary<int, Aabb[]>();
var builder = new Dictionary<int, Aabb[]>();
if (prismarineBlocks is null || prismarineShapes is null)
{
stateToShape = builder.ToFrozenDictionary();
return;
}
var palette = Block.Palette;
var dict = GetPaletteDict(palette);
if (dict is null) return;
if (dict is null)
{
stateToShape = builder.ToFrozenDictionary();
return;
}
// Group consecutive state IDs by Material to find state ranges per block
var materialRanges = new Dictionary<Material, List<(int start, int end)>>();
@ -182,20 +190,20 @@ namespace MinecraftClient.Physics
{
var shapes = prismarineShapes.GetValueOrDefault(singleShapeId, EmptyArray);
for (int sid = start; sid <= end; sid++)
stateToShape[sid] = shapes;
builder[sid] = shapes;
}
else if (blockShapeData is List<int> shapeIdList)
{
for (int i = 0; i < stateCount && (globalStateOffset + i) < shapeIdList.Count; i++)
{
int shapeId = shapeIdList[globalStateOffset + i];
stateToShape[start + i] = prismarineShapes.GetValueOrDefault(shapeId, EmptyArray);
builder[start + i] = prismarineShapes.GetValueOrDefault(shapeId, EmptyArray);
}
}
globalStateOffset += stateCount;
}
}
stateToShape = builder.ToFrozenDictionary();
}
/// <summary>

View file

@ -4,6 +4,8 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -161,14 +163,7 @@ namespace MinecraftClient
}
if (configResult.NeedWriteDefault)
{
Config.Main.Advanced.Language = Settings.GetDefaultGameLanguage();
WriteBackSettings(false);
}
else if (configResult.Success)
{
WriteBackSettings(true);
}
if (!Config.Main.Advanced.EnableSentry)
_sentrySdk?.Dispose();
@ -181,12 +176,22 @@ namespace MinecraftClient
};
// --- Determine console mode and initialize backend ---
if (!OperatingSystem.IsWindows())
InstallCursesNativeResolver();
if (!ConsoleIO.BasicIO && Config.Console.General.ConsoleMode == ConsoleModeType.tui)
{
ConsoleIO.Backend?.Shutdown();
var tuiBackend = new Tui.TuiConsoleBackend();
ConsoleIO.Backend = tuiBackend;
tuiBackend.RunTuiMainLoop(args, startupState);
try
{
var tuiBackend = new Tui.TuiConsoleBackend();
ConsoleIO.Backend = tuiBackend;
tuiBackend.RunTuiMainLoop(args, startupState);
}
catch (Exception ex)
{
HandleTuiStartupFailure(ex);
}
return;
}
@ -200,9 +205,46 @@ namespace MinecraftClient
if (!ProcessStartupState(startupState))
return;
// Wait for this issue to be fixed before enabling it: https://github.com/Consolonia/Consolonia/issues/602
// MaybePrintClassicModeTuiRecommendation();
RunStartupSequence(args);
}
/// <summary>
/// Consolonia's Unix.Terminal uses <c>[DllImport("libcoreclr.so")]</c> to reach
/// <c>dlopen</c>/<c>dlsym</c> on .NET Core. The library ships a
/// <c>SetDllImportResolver</c> that maps <c>libcoreclr.so</c> to the current
/// process, but it is compiled under <c>#if NET6_0</c> (exact TFM match) instead
/// of <c>NET6_0_OR_GREATER</c>, so it is dead code when the consuming project
/// targets net8.0+. On a self-contained single-file publish the physical
/// <c>libcoreclr.so</c> does not exist on the search path, causing a
/// <c>DllNotFoundException</c> that crashes the TUI.
///
/// We work around this by registering our own resolver before any Consolonia
/// code runs: if any assembly asks for <c>libcoreclr.so</c> we return
/// <c>(IntPtr)(-1)</c> which the runtime interprets as "the current process".
/// </summary>
private static void InstallCursesNativeResolver()
{
AssemblyLoadContext.Default.ResolvingUnmanagedDll += (assembly, libraryName) =>
libraryName == "libcoreclr.so" ? (IntPtr)(-1) : IntPtr.Zero;
}
private static void HandleTuiStartupFailure(Exception exception)
{
Config.Console.General.ConsoleMode = ConsoleModeType.classic;
WriteBackSettings(enableBackup: false);
ConsoleIO.Backend = new ClassicConsoleBackend();
ConsoleIO.Backend.Init();
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_tui_startup_failed);
ConsoleIO.WriteLine(exception.ToString());
ConsoleIO.WriteLineFormatted("§e" + Translations.mcc_report_issue);
ConsoleIO.WriteLineFormatted("§e" + Translations.mcc_tui_startup_fallback_classic);
}
/// <summary>
/// Prints the application banner and processes the startup state collected before
/// the console backend was ready. Called once from classic mode or from TUI after
@ -211,14 +253,33 @@ namespace MinecraftClient
/// <returns>True if startup can continue; false if config load failed and user chose to exit.</returns>
internal static bool ProcessStartupState(StartupState state)
{
ConsoleIO.WriteLine($"Minecraft Console Client v{Version} - for MC {MCLowestVersion} to {MCHighestVersion} - Github.com/MCCTeam");
if (BuildInfo is not null)
ConsoleIO.WriteLineFormatted("§8" + BuildInfo);
if (Config.Console.General.Display_Icon_Banner && ConsoleIO.Backend is Tui.TuiConsoleBackend tuiBanner)
{
var view = tuiBanner.GetView();
if (view is not null)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var panel = Tui.MccBannerPanelBuilder.Build(BuildInfo);
view.AppendControlToLog(panel);
});
}
else
{
ShowClassicBanner();
}
}
else
{
ShowClassicBanner();
}
var cfg = state.ConfigResult;
if (cfg.NeedWriteDefault)
{
WriteBackSettings(false);
if (cfg.IsLegacyUpgrade)
{
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_use_new_config);
@ -243,6 +304,8 @@ namespace MinecraftClient
}
else
{
WriteBackSettings(true);
if (!Config.Main.Advanced.Language.StartsWith("en"))
ConsoleIO.WriteLine(string.Format(Translations.mcc_help_us_translate, Settings.TranslationProjectUrl));
}
@ -250,6 +313,26 @@ namespace MinecraftClient
return true;
}
private static void ShowClassicBanner()
{
ConsoleIO.WriteLine(string.Format(Translations.mcc_banner_classic, Version, MCLowestVersion, MCHighestVersion, "Github.com/MCCTeam"));
if (BuildInfo is not null)
ConsoleIO.WriteLineFormatted("§8" + BuildInfo);
}
private static void MaybePrintClassicModeTuiRecommendation()
{
if (ConsoleIO.BasicIO
|| Config.Console.General.ConsoleMode != ConsoleModeType.classic
|| Console.IsInputRedirected)
{
return;
}
char cmdChar = Config.Main.Advanced.InternalCmdChar.ToChar();
ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.mcc_console_mode_tui_recommendation, cmdChar));
}
/// <summary>
/// Handles a failed config load by prompting the user to fix or regenerate the config file.
/// </summary>

View file

@ -411,6 +411,37 @@ namespace MinecraftClient.Protocol.Handlers
return ReadNextNbt(cache, true);
}
/// <summary>
/// Read an ItemStackTemplate (26.1+) from a cache of bytes.
/// Unlike ItemStack, this uses item-first encoding: item_id, count, DataComponentPatch.
/// ItemStackTemplate is always non-empty (no count=0 sentinel).
/// </summary>
public Item ReadNextItemStackTemplate(Queue<byte> cache, ItemPalette itemPalette)
{
var itemId = ReadNextVarInt(cache);
var itemCount = ReadNextVarInt(cache);
var item = new Item(itemPalette.FromId(itemId), itemCount, null);
var numberOfComponentsToAdd = ReadNextVarInt(cache);
var numberofComponentsToRemove = ReadNextVarInt(cache);
var structuredComponentHandler = new StructuredComponentsHandler(protocolversion, this, itemPalette);
var strcturedComponentsToAdd = new List<StructuredComponent>(numberOfComponentsToAdd);
for (var i = 0; i < numberOfComponentsToAdd; i++)
{
var componentTypeId = ReadNextVarInt(cache);
strcturedComponentsToAdd.Add(structuredComponentHandler.Parse(componentTypeId, cache));
}
for (var i = 0; i < numberofComponentsToRemove; i++)
ReadNextVarInt(cache);
if (strcturedComponentsToAdd.Count > 0)
item.Components = strcturedComponentsToAdd;
return item;
}
/// <summary>
/// Read a single item slot from a cache of bytes and remove it from the cache
/// </summary>
@ -664,8 +695,10 @@ namespace MinecraftClient.Protocol.Handlers
}
}
return new Entity(entityID, entityType, new Location(entityX, entityY, entityZ), entityYaw, entityPitch,
var entity = new Entity(entityID, entityType, new Location(entityX, entityY, entityZ), entityYaw, entityPitch,
data);
entity.UUID = entityUUID;
return entity;
}
/// <summary>
@ -1021,20 +1054,44 @@ namespace MinecraftClient.Protocol.Handlers
}
}
private static bool HasLpVec3Continuation(int firstByte) => (firstByte & 4) == 4;
private static double UnpackLpVec3(long packedAxis)
{
return Math.Min((double)(packedAxis & 32767L), 32766.0) * 2.0 / 32766.0 - 1.0;
}
/// <summary>
/// Read an LpVec3 (low-precision vec3) from the cache (1.21.9+).
/// Variable-length encoding: first byte 0 = zero vector; otherwise
/// 2 bytes + 4 bytes (6 total), plus an optional VarInt continuation.
/// Read and decode an LpVec3 (low-precision vec3) from the cache (1.21.9+).
/// Returned vector is expressed in blocks per tick.
/// </summary>
public void ReadNextLpVec3(Queue<byte> cache)
public (double X, double Y, double Z) ReadNextLpVec3Values(Queue<byte> cache)
{
int first = ReadNextByte(cache);
if (first == 0)
return;
ReadNextByte(cache); // second byte
ReadData(4, cache); // uint32
if ((first & 4) == 4) // continuation bit set
ReadNextVarInt(cache);
return (0.0, 0.0, 0.0);
int second = ReadNextByte(cache);
uint high = (uint)ReadNextInt(cache);
long packed = ((long)high << 16) | (long)(second << 8) | (uint)first;
long scale = first & 3;
if (HasLpVec3Continuation(first))
scale |= ((long)ReadNextVarInt(cache) & 0xFFFFFFFFL) << 2;
return (
UnpackLpVec3(packed >> 3) * scale,
UnpackLpVec3(packed >> 18) * scale,
UnpackLpVec3(packed >> 33) * scale
);
}
/// <summary>
/// Read an LpVec3 (low-precision vec3) from the cache (1.21.9+) and discard it.
/// </summary>
public void ReadNextLpVec3(Queue<byte> cache)
{
ReadNextLpVec3Values(cache);
}
/// <summary>

View file

@ -811,6 +811,11 @@ namespace MinecraftClient.Protocol.Handlers
return false; //Currently not implemented
}
public bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll)
{
return false; //MC 1.8-1.12.1 recipe book not supported
}
public bool SendCloseWindow(int windowId)
{
return false; //Currently not implemented

File diff suppressed because it is too large Load diff

View file

@ -190,6 +190,15 @@ namespace MinecraftClient.Protocol
bool ClickContainerButton(int windowId, int buttonId);
/// <summary>
/// Send a place recipe packet to the server for the active recipe book container.
/// </summary>
/// <param name="windowId">Id of the window being clicked</param>
/// <param name="recipeId">Recipe identifier to craft</param>
/// <param name="makeAll">True to craft as many items as possible</param>
/// <returns>True if packet was successfully sent</returns>
bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll);
/// <summary>
/// Plays animation
/// </summary>

View file

@ -295,6 +295,16 @@ namespace MinecraftClient.Protocol
/// <param name="onGround">TRUE if on ground</param>
void OnEntityTeleport(int entityID, Double x, Double y, Double z, bool onGround);
/// <summary>
/// Called when an entity velocity update packet is received.
/// Velocity values are in blocks per tick.
/// </summary>
/// <param name="entityID">Entity ID</param>
/// <param name="velocityX">Velocity X</param>
/// <param name="velocityY">Velocity Y</param>
/// <param name="velocityZ">Velocity Z</param>
void OnEntityVelocity(int entityID, double velocityX, double velocityY, double velocityZ);
/// <summary>
/// Called when additional properties have been received for an entity
/// </summary>
@ -371,6 +381,17 @@ namespace MinecraftClient.Protocol
/// <param name="affectedBlocks">Amount of affected blocks</param>
void OnExplosion(Location location, float strength, int affectedBlocks);
/// <summary>
/// Called when a sound packet is received.
/// </summary>
/// <param name="soundName">Sound key if available, otherwise null</param>
/// <param name="location">Sound location for world sounds, or null if unavailable</param>
/// <param name="category">Sound category id</param>
/// <param name="volume">Sound volume</param>
/// <param name="pitch">Sound pitch</param>
/// <param name="entityID">Source entity id for entity-sound packets, if any</param>
void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch, int? entityID);
/// <summary>
/// Called when a player's game mode has changed
/// </summary>
@ -468,6 +489,23 @@ namespace MinecraftClient.Protocol
/// <param name="numberFormat">Number format: 0 - blank, 1 - styled, 2 - fixed</param>
void OnUpdateScore(string entityName, int action, string objectiveName, string objectiveDisplayName, int objectiveValue, int numberFormat);
/// <summary>
/// Called when a Teams packet is received from the server.
/// </summary>
/// <param name="teamName">Internal team name (up to 16 chars)</param>
/// <param name="method">0=create, 1=remove, 2=update, 3=add players, 4=remove players</param>
/// <param name="displayName">Display name (formatted). Present when method is 0 or 2.</param>
/// <param name="friendlyFlags">Bit 0=allowFriendlyFire, bit 1=seeFriendlyInvisibles. Present when method is 0 or 2.</param>
/// <param name="nameTagVisibility">Nametag visibility rule string. Present when method is 0 or 2.</param>
/// <param name="collisionRule">Collision rule string. Present when method is 0 or 2.</param>
/// <param name="color">ChatFormatting color value (-1=none). Present when method is 0 or 2.</param>
/// <param name="prefix">Member name prefix (formatted). Present when method is 0 or 2.</param>
/// <param name="suffix">Member name suffix (formatted). Present when method is 0 or 2.</param>
/// <param name="players">Player/entity names. Present when method is 0, 3, or 4.</param>
void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags,
string nameTagVisibility, string collisionRule, int color,
string prefix, string suffix, List<string> players);
/// <summary>
/// Called when the client received the Tab Header and Footer
/// </summary>
@ -524,6 +562,33 @@ namespace MinecraftClient.Protocol
public void SetCanSendMessage(bool canSendMessage);
/// <summary>
/// Called when recipe book recipes are added or replaced.
/// </summary>
/// <param name="recipes">Recipe entries to add</param>
/// <param name="replace">True to replace the currently tracked recipe book entries</param>
public void OnRecipeBookAdd(RecipeBookRecipeEntry[] recipes, bool replace);
/// <summary>
/// Called when recipe book recipes are removed.
/// </summary>
/// <param name="recipeIds">Recipe identifiers to remove</param>
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>
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom

View file

@ -6,6 +6,7 @@ using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using DnsClient;
using MinecraftClient.Protocol.Handlers;
using MinecraftClient.Protocol.Handlers.Forge;
@ -388,6 +389,61 @@ namespace MinecraftClient.Protocol
}
}
private static readonly Regex VersionTokenRegex = new(@"\d+\.\d+(?:\.\d+)?", RegexOptions.Compiled);
private static readonly int[] SupportedProtocols18 =
[
4, 5, 47, 107, 108, 109, 110, 210, 315, 316, 335, 338, 340, 393, 401, 404,
477, 480, 485, 490, 498, 573, 575, 578, 735, 736, 751, 753, 754, 755, 756,
757, 758, 759, 760, 761, 762, 763, 764, 765, 766, 767, 768, 769, 770, 771,
772, 773, 774, 775
];
/// <summary>
/// For multi-version servers (e.g. "Requires MC 1.8 / 1.21"), try to find the
/// highest protocol version that both the server and MCC support.
/// Returns true if the protocol was upgraded, with the new value in
/// <paramref name="protocolVersion"/>.
/// </summary>
public static bool TryUpgradeProtocolVersion(string versionName, ref int protocolVersion)
{
if (string.IsNullOrEmpty(versionName))
return false;
var matches = VersionTokenRegex.Matches(versionName);
if (matches.Count < 2)
return false;
int bestProtocol = protocolVersion;
string bestVersion = "";
foreach (Match m in matches)
{
int proto = MCVer2ProtocolVersion(m.Value);
if (proto <= 0)
continue;
if (Array.IndexOf(SupportedProtocols18, proto) < 0)
continue;
if (proto > bestProtocol)
{
bestProtocol = proto;
bestVersion = m.Value;
}
}
if (bestProtocol > protocolVersion && bestVersion.Length > 0)
{
ConsoleIO.WriteLineFormatted("§8" + string.Format(
Translations.mcc_server_info_version_upgrade,
ProtocolVersion2MCVer(protocolVersion), protocolVersion,
"§a" + bestVersion + "§8", bestProtocol));
protocolVersion = bestProtocol;
return true;
}
return false;
}
/// <summary>
/// Convert a network protocol version number to human-readable Minecraft version number
/// </summary>

View file

@ -0,0 +1,119 @@
using System;
using System.Text;
using MinecraftClient.Protocol.Message;
using MinecraftClient.Scripting;
namespace MinecraftClient.Protocol
{
internal static class ServerStatusDisplay
{
private const int MaxSamplePlayers = 10;
internal static void Show(ServerStatusInfo info)
{
if (ConsoleIO.Backend is Tui.TuiConsoleBackend tuiBackend)
ShowTui(info, tuiBackend);
else
ShowClassic(info);
}
private static void ShowClassic(ServerStatusInfo info)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.Append("§8§m");
sb.Append(new string('-', 50));
sb.AppendLine("§r");
if (!string.IsNullOrEmpty(info.MotdRaw))
{
try
{
sb.AppendLine(ChatParser.ParseText(info.MotdRaw));
}
catch
{
sb.AppendLine(info.MotdRaw);
}
}
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_server);
sb.Append(" §b");
sb.Append(info.Host);
sb.Append("§7:§b");
sb.AppendLine(info.Port.ToString());
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_version);
sb.Append(" §b");
sb.Append(ChatBot.GetVerbatim(info.VersionName));
sb.Append(" §7(");
sb.Append(string.Format(Translations.mcc_server_info_label_protocol, "§e" + info.ProtocolVersion + "§7"));
sb.AppendLine(")");
if (info.ResolvedProtocol != 0)
{
string resolvedMcVer = ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol);
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_connecting_as);
sb.Append(" §a");
sb.Append(resolvedMcVer);
sb.Append(" §7(");
sb.Append(string.Format(Translations.mcc_server_info_label_protocol, "§a" + info.ResolvedProtocol + "§7"));
sb.AppendLine(")");
}
if (info.PingMs >= 0)
{
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_ping);
sb.Append(" §a");
sb.AppendLine(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs));
}
sb.Append("§f");
sb.Append(Translations.mcc_server_info_label_players);
sb.Append(" §a");
sb.Append(info.OnlinePlayers);
sb.Append("§7/§c");
sb.AppendLine(info.MaxPlayers.ToString());
if (info.SamplePlayers.Count > 0)
{
sb.Append("§f");
sb.AppendLine(Translations.mcc_server_info_label_online);
int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers);
for (int i = 0; i < shown; i++)
sb.AppendLine($" §a{info.SamplePlayers[i].Name}");
if (info.SamplePlayers.Count > shown)
sb.AppendLine($" §7{string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}");
}
sb.Append("§8§m");
sb.Append(new string('-', 50));
sb.Append("§r");
ConsoleIO.WriteLineFormatted(sb.ToString(), acceptnewlines: true);
}
private static void ShowTui(ServerStatusInfo info, Tui.TuiConsoleBackend backend)
{
var view = backend.GetView();
if (view is null)
{
ShowClassic(info);
return;
}
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var panel = Tui.ServerStatusPanelBuilder.Build(info);
view.AppendControlToLog(panel);
});
}
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace MinecraftClient.Protocol
{
/// <summary>
/// Holds the structured result of a Minecraft server status (SLP) ping,
/// including MOTD, player counts, sample player list, version, and favicon.
/// </summary>
public sealed class ServerStatusInfo
{
public string Host { get; init; } = string.Empty;
public int Port { get; init; }
public string VersionName { get; init; } = string.Empty;
public int ProtocolVersion { get; init; }
public int ResolvedProtocol { get; set; }
public int OnlinePlayers { get; init; }
public int MaxPlayers { get; init; }
public List<SamplePlayer> SamplePlayers { get; init; } = [];
public string MotdRaw { get; init; } = string.Empty;
public string? FaviconBase64 { get; init; }
public long PingMs { get; init; }
public sealed class SamplePlayer
{
public string Name { get; init; } = string.Empty;
public string Id { get; init; } = string.Empty;
}
}
}

View file

@ -0,0 +1,4 @@
namespace MinecraftClient
{
public readonly record struct RecipeBookRecipeEntry(string CommandId, string DisplayText);
}

File diff suppressed because it is too large Load diff

View file

@ -311,6 +311,21 @@ You can use "/fish" to control the bot manually.
<data name="ChatBot.AutoFishing.Hook_Threshold" xml:space="preserve">
<value>A "stationary" hook that moves above this threshold in the Y-axis will be considered to have caught a fish.</value>
</data>
<data name="ChatBot.AutoFishing.Enable_Velocity_Detection" xml:space="preserve">
<value>Enable fish bite detection using fishing bobber velocity packets.</value>
</data>
<data name="ChatBot.AutoFishing.Velocity_Hook_Threshold" xml:space="preserve">
<value>Velocity Y threshold (blocks/tick). Values below this are treated as a bite. Keep this value negative.</value>
</data>
<data name="ChatBot.AutoFishing.Enable_Sound_Detection" xml:space="preserve">
<value>Enable fish bite detection using splash sounds near the fishing bobber.</value>
</data>
<data name="ChatBot.AutoFishing.Sound_Distance" xml:space="preserve">
<value>Maximum distance (blocks) between splash sound and bobber to treat it as a bite.</value>
</data>
<data name="ChatBot.AutoFishing.Detection_Warmup" xml:space="preserve">
<value>Delay (seconds) after bobber spawn before bite detection starts. Helps ignore cast-entry splash/motion.</value>
</data>
<data name="ChatBot.AutoFishing.Log_Fish_Bobber" xml:space="preserve">
<value>Used to adjust the above two thresholds, which when enabled will print the change in the position of the fishhook entity upon receipt of its movement packet.</value>
</data>
@ -393,6 +408,12 @@ For Discord message formatting, check the following: https://mccteam.github.io/r
<data name="ChatBot.DiscordBridge.AllowOtherBotMessages" xml:space="preserve">
<value>When enabled, messages from other Discord bots in the channel will be relayed to Minecraft chat. The bridge always ignores its own messages to prevent loops.</value>
</data>
<data name="ChatBot.DiscordBridge.RelayAllMessages" xml:space="preserve">
<value>When enabled, all text received from the Minecraft server (including system messages, join/leave notifications, etc.) will be relayed to Discord, not just player chat and private messages.</value>
</data>
<data name="ChatBot.DiscordBridge.MessageAggregationInterval" xml:space="preserve">
<value>Interval in seconds to aggregate messages before sending them to Discord. When set to 0, messages are sent immediately one by one. When set to a value like 1.0, messages received within that interval are batched into a single Discord message. Useful for reducing Discord API rate limits.</value>
</data>
<data name="ChatBot.Farmer" xml:space="preserve">
<value>Automatically farms crops for you (plants, breaks and bonemeals them).
Crop types available: Beetroot, Carrot, Melon, Netherwart, Pumpkin, Potato, Wheat.
@ -560,9 +581,18 @@ Custom colors are only available when using "vt100_24bit" color mode.</value>
<data name="Console.General.ConsoleColorMode" xml:space="preserve">
<value>Use "disable", "legacy_4bit", "vt100_4bit", "vt100_8bit" or "vt100_24bit". If a garbled code like "←[0m" appears on the terminal, you can try switching to "legacy_4bit" mode, or just disable it.</value>
</data>
<data name="Console.General.Display_Icon_Banner" xml:space="preserve">
<value>Whether to display the MCC startup icon banner.</value>
</data>
<data name="Console.General.Display_Input" xml:space="preserve">
<value>You can use "Ctrl+P" to print out the current input and cursor position.</value>
</data>
<data name="Console.General.History_Input_Records" xml:space="preserve">
<value>Maximum number of input history records to keep.</value>
</data>
<data name="Console.General.TUI_Log_Scrollback" xml:space="preserve">
<value>Maximum log lines kept in TUI mode scrollback. Set to 0 for automatic.</value>
</data>
<data name="Head" xml:space="preserve">
<value>Startup Config File
Please do not record extraneous data in this file as it will be overwritten by MCC.
@ -933,6 +963,42 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be
<data name="Main.Advanced.enable_sentry" xml:space="preserve">
<value>Set to false to opt-out of Sentry error logging.</value>
</data>
<data name="Console.Minimap" xml:space="preserve">
<value>Settings for the TUI minimap overlay that shows terrain and entities.</value>
</data>
<data name="Console.Minimap.Enabled" xml:space="preserve">
<value>Whether the minimap is visible on startup in TUI mode.</value>
</data>
<data name="Console.Minimap.Zoom" xml:space="preserve">
<value>Blocks per pixel, 1-16. 1 = closest (1:1), 16 = farthest (16 blocks per pixel).</value>
</data>
<data name="Console.Minimap.Width" xml:space="preserve">
<value>Map width in pixels (characters). Range 10-120, default 40.</value>
</data>
<data name="Console.Minimap.Height" xml:space="preserve">
<value>Map height in pixels (must be even, uses half-block chars). Range 4-80, default 40.</value>
</data>
<data name="Console.Minimap.Position" xml:space="preserve">
<value>Minimap position: "top_left", "top_right", "center", "bottom_left", or "bottom_right".</value>
</data>
<data name="Console.Minimap.ShowPlayerNames" xml:space="preserve">
<value>Show player names on the minimap.</value>
</data>
<data name="Console.Minimap.ShowHostileNames" xml:space="preserve">
<value>Show hostile mob names on the minimap.</value>
</data>
<data name="Console.Minimap.ShowNeutralNames" xml:space="preserve">
<value>Show neutral mob names on the minimap.</value>
</data>
<data name="Console.Minimap.ShowPassiveNames" xml:space="preserve">
<value>Show passive mob names on the minimap.</value>
</data>
<data name="Console.Minimap.RefreshInterval" xml:space="preserve">
<value>Minimap refresh interval in milliseconds (100-5000).</value>
</data>
<data name="Console.Minimap.CaveMode" xml:space="preserve">
<value>Cave rendering mode: "auto" (detect ceiling), "on" (always cave view), "off" (always surface view).</value>
</data>
<data name="Main.General.AuthlibUser" xml:space="preserve">
<value>Yggdrasil authlib multi-user selection.</value>
</data>

View file

@ -437,6 +437,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Dropped low durability {0} from slot {1}..
/// </summary>
internal static string bot_autodig_drop_low_durability {
get {
return ResourceManager.GetString("bot.autodig.drop_low_durability", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The block currently pointed to is not in the allowed list..
/// </summary>
@ -473,6 +482,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Switch to {0} from slot {1}..
/// </summary>
internal static string bot_autodig_switch {
get {
return ResourceManager.GetString("bot.autodig.switch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Added item {0}.
/// </summary>
@ -879,6 +897,24 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Waiting {0:0.000} seconds before reconnecting... ({1} retries left).
/// </summary>
internal static string bot_autoRelog_wait_with_retries {
get {
return ResourceManager.GetString("bot.autoRelog.wait_with_retries", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to unlimited.
/// </summary>
internal static string bot_autoRelog_retries_unlimited {
get {
return ResourceManager.GetString("bot.autoRelog.retries_unlimited", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to File not found: &apos;{0}&apos;.
/// </summary>
@ -2269,6 +2305,78 @@ namespace MinecraftClient {
}
}
internal static string mcc_banner_classic {
get {
return ResourceManager.GetString("mcc.banner.classic", resourceCulture);
}
}
internal static string mcc_banner_label_mc_versions {
get {
return ResourceManager.GetString("mcc.banner.label_mc_versions", resourceCulture);
}
}
internal static string mcc_server_info_label_server {
get {
return ResourceManager.GetString("mcc.server_info.label_server", resourceCulture);
}
}
internal static string mcc_server_info_label_version {
get {
return ResourceManager.GetString("mcc.server_info.label_version", resourceCulture);
}
}
internal static string mcc_server_info_label_protocol {
get {
return ResourceManager.GetString("mcc.server_info.label_protocol", resourceCulture);
}
}
internal static string mcc_server_info_label_players {
get {
return ResourceManager.GetString("mcc.server_info.label_players", resourceCulture);
}
}
internal static string mcc_server_info_label_ping {
get {
return ResourceManager.GetString("mcc.server_info.label_ping", resourceCulture);
}
}
internal static string mcc_server_info_label_ping_ms {
get {
return ResourceManager.GetString("mcc.server_info.label_ping_ms", resourceCulture);
}
}
internal static string mcc_server_info_label_connecting_as {
get {
return ResourceManager.GetString("mcc.server_info.label_connecting_as", resourceCulture);
}
}
internal static string mcc_server_info_label_online {
get {
return ResourceManager.GetString("mcc.server_info.label_online", resourceCulture);
}
}
internal static string mcc_server_info_sample_more {
get {
return ResourceManager.GetString("mcc.server_info.sample_more", resourceCulture);
}
}
internal static string mcc_server_info_version_upgrade {
get {
return ResourceManager.GetString("mcc.server_info.version_upgrade", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Converting session cache from disk: {0}.
/// </summary>
@ -3558,6 +3666,51 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to quickly enable recommended features..
/// </summary>
internal static string cmd_tryout_desc {
get {
return ResourceManager.GetString("cmd.tryout.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Available quick actions:.
/// </summary>
internal static string cmd_tryout_list_header {
get {
return ResourceManager.GetString("cmd.tryout.list.header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to tui: set [Console.General] ConsoleMode = &quot;tui&quot; for the next restart..
/// </summary>
internal static string cmd_tryout_list_tui {
get {
return ResourceManager.GetString("cmd.tryout.list.tui", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to [Console.General] ConsoleMode is already &quot;tui&quot; in the config. To switch back, set [Console.General] ConsoleMode = &quot;classic&quot;. Restart MCC after changing it for the new mode to take effect..
/// </summary>
internal static string cmd_tryout_tui_already_enabled {
get {
return ResourceManager.GetString("cmd.tryout.tui.already_enabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Updated [Console.General] ConsoleMode from &quot;{0}&quot; to &quot;{1}&quot; in the config. To switch back, set [Console.General] ConsoleMode = &quot;classic&quot;. Restart MCC to apply the change..
/// </summary>
internal static string cmd_tryout_tui_enabled {
get {
return ResourceManager.GetString("cmd.tryout.tui.enabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Display Health and Food saturation..
/// </summary>
@ -4254,6 +4407,87 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.nameitem.successful", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to send recipe book craft request for {0}..
/// </summary>
internal static string cmd_recipebook_craft_failed {
get {
return ResourceManager.GetString("cmd.recipebook.craft.failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Requested recipe {0}..
/// </summary>
internal static string cmd_recipebook_craft_sent {
get {
return ResourceManager.GetString("cmd.recipebook.craft.sent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Requested recipe {0} with craft-all..
/// </summary>
internal static string cmd_recipebook_craftall_sent {
get {
return ResourceManager.GetString("cmd.recipebook.craftall.sent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to List unlocked recipe book recipes and craft them through the active recipe book inventory..
/// </summary>
internal static string cmd_recipebook_desc {
get {
return ResourceManager.GetString("cmd.recipebook.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unlocked recipe book recipes.
/// </summary>
internal static string cmd_recipebook_list {
get {
return ResourceManager.GetString("cmd.recipebook.list", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory..
/// </summary>
internal static string cmd_recipebook_no_active_inventory {
get {
return ResourceManager.GetString("cmd.recipebook.no.active.inventory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No unlocked recipe book recipes are currently tracked..
/// </summary>
internal static string cmd_recipebook_no_recipes {
get {
return ResourceManager.GetString("cmd.recipebook.no.recipes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The recipe identifier cannot be empty..
/// </summary>
internal static string cmd_recipebook_recipe_id_empty {
get {
return ResourceManager.GetString("cmd.recipebook.recipe.id.empty", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Recipe book crafting is only supported on Minecraft 1.13 and newer..
/// </summary>
internal static string cmd_recipebook_unsupported {
get {
return ResourceManager.GetString("cmd.recipebook.unsupported", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to restart and reconnect to the server..
@ -4462,6 +4696,51 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to List all scoreboard teams and their members.
/// </summary>
internal static string cmd_teams_desc {
get {
return ResourceManager.GetString("cmd.teams.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No teams are currently tracked.
/// </summary>
internal static string cmd_teams_no_teams {
get {
return ResourceManager.GetString("cmd.teams.no_teams", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Team '{0}' (display: {1}, ...).
/// </summary>
internal static string cmd_teams_team_header {
get {
return ResourceManager.GetString("cmd.teams.team_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Members ({0}): {1}.
/// </summary>
internal static string cmd_teams_team_members {
get {
return ResourceManager.GetString("cmd.teams.team_members", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No members.
/// </summary>
internal static string cmd_teams_team_no_members {
get {
return ResourceManager.GetString("cmd.teams.team_no_members", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Place a block or open chest.
/// </summary>
@ -5534,6 +5813,42 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Tip: try TUI mode for a cleaner interface, mouse-friendly container actions, and a nicer layout. Run {0}feature tui§8 to switch [Console.General] ConsoleMode to &quot;tui&quot; for the next restart..
/// </summary>
internal static string mcc_console_mode_tui_recommendation {
get {
return ResourceManager.GetString("mcc.console_mode_tui_recommendation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to MCC encountered a problem while starting TUI mode..
/// </summary>
internal static string mcc_tui_startup_failed {
get {
return ResourceManager.GetString("mcc.tui_startup_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to As a fallback, MCC has automatically switched [Console.General] ConsoleMode to &quot;classic&quot;. This will take effect after you restart MCC..
/// </summary>
internal static string mcc_tui_startup_fallback_classic {
get {
return ResourceManager.GetString("mcc.tui_startup_fallback_classic", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Please report this issue to the MCC Team..
/// </summary>
internal static string mcc_report_issue {
get {
return ResourceManager.GetString("mcc.report_issue", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to To sign in, open {0} in your browser and enter the code: {1}.
/// </summary>
@ -5861,6 +6176,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Cannot send text: not connected to a server..
/// </summary>
internal static string mcc_send_text_not_connected {
get {
return ResourceManager.GetString("mcc.send_text_not_connected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Waiting {0} seconds before restarting....
/// </summary>
@ -6799,7 +7123,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("tui.crafting.grid", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Starting embedded MCP server....
/// </summary>
@ -6808,7 +7132,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.starting", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded MCP server started on {0}.
/// </summary>
@ -6817,7 +7141,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.started", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to start embedded MCP server: {0}.
/// </summary>
@ -6826,7 +7150,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.start_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded MCP auth token is required but environment variable {0} is empty..
/// </summary>
@ -6835,7 +7159,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.missing_auth_token", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Embedded MCP server stopped..
/// </summary>
@ -6844,7 +7168,7 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.stopped", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to stop embedded MCP server cleanly: {0}.
/// </summary>
@ -6853,5 +7177,275 @@ namespace MinecraftClient {
return ResourceManager.GetString("bot.mcpserver.stop_failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Toggle the TUI minimap overlay, or adjust its zoom level..
/// </summary>
internal static string cmd_minimap_desc {
get {
return ResourceManager.GetString("cmd.minimap.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap enabled..
/// </summary>
internal static string cmd_minimap_enabled {
get {
return ResourceManager.GetString("cmd.minimap.enabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap disabled..
/// </summary>
internal static string cmd_minimap_disabled {
get {
return ResourceManager.GetString("cmd.minimap.disabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap zoom set to {0}:1 (blocks per pixel)..
/// </summary>
internal static string cmd_minimap_zoom_set {
get {
return ResourceManager.GetString("cmd.minimap.zoom_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Current minimap zoom: {0}:1 blocks/px (range 1-{1})..
/// </summary>
internal static string cmd_minimap_zoom_current {
get {
return ResourceManager.GetString("cmd.minimap.zoom_current", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The minimap command is only available in TUI mode..
/// </summary>
internal static string cmd_minimap_tui_only {
get {
return ResourceManager.GetString("cmd.minimap.tui_only", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hostile.
/// </summary>
internal static string tui_minimap_legend_hostile {
get {
return ResourceManager.GetString("tui.minimap.legend.hostile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Passive.
/// </summary>
internal static string tui_minimap_legend_passive {
get {
return ResourceManager.GetString("tui.minimap.legend.passive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Neutral.
/// </summary>
internal static string tui_minimap_legend_neutral {
get {
return ResourceManager.GetString("tui.minimap.legend.neutral", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Player.
/// </summary>
internal static string tui_minimap_legend_player {
get {
return ResourceManager.GetString("tui.minimap.legend.player", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name display -- Players: {0}, Hostile: {1}, Neutral: {2}, Passive: {3}.
/// </summary>
internal static string cmd_minimap_names_status {
get {
return ResourceManager.GetString("cmd.minimap.names_status", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to All entity name labels enabled..
/// </summary>
internal static string cmd_minimap_names_all_on {
get {
return ResourceManager.GetString("cmd.minimap.names_all_on", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to All entity name labels disabled..
/// </summary>
internal static string cmd_minimap_names_all_off {
get {
return ResourceManager.GetString("cmd.minimap.names_all_off", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} name display: {1}.
/// </summary>
internal static string cmd_minimap_names_cat {
get {
return ResourceManager.GetString("cmd.minimap.names_cat", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} name display set to {1}..
/// </summary>
internal static string cmd_minimap_names_cat_set {
get {
return ResourceManager.GetString("cmd.minimap.names_cat_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Current minimap position: {0}.
/// </summary>
internal static string cmd_minimap_position_current {
get {
return ResourceManager.GetString("cmd.minimap.position_current", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimap position set to: {0}.
/// </summary>
internal static string cmd_minimap_position_set {
get {
return ResourceManager.GetString("cmd.minimap.position_set", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Current cave mode: {0}.
/// </summary>
internal static string cmd_minimap_cave_current {
get {
return ResourceManager.GetString("cmd.minimap.cave_current", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Cave mode set to: {0}.
/// </summary>
internal static string cmd_minimap_cave_set {
get {
return ResourceManager.GetString("cmd.minimap.cave_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

@ -243,6 +243,9 @@
<data name="bot.autodig.no_inv_handle" xml:space="preserve">
<value>Inventory handling is not enabled. Unable to switch tools automatically.</value>
</data>
<data name="bot.autodig.drop_low_durability" xml:space="preserve">
<value>Dropped low durability {0} from slot {1}.</value>
</data>
<data name="bot.autodig.start" xml:space="preserve">
<value>Automatic digging has started.</value>
</data>
@ -252,6 +255,9 @@
<data name="bot.autodig.stop" xml:space="preserve">
<value>Auto-digging has been stopped.</value>
</data>
<data name="bot.autodig.switch" xml:space="preserve">
<value>Switch to {0} from slot {1}.</value>
</data>
<data name="bot.autoDrop.added" xml:space="preserve">
<value>Added item {0}</value>
</data>
@ -388,6 +394,12 @@
<data name="bot.autoRelog.wait" xml:space="preserve">
<value>Waiting {0:0.000} seconds before reconnecting...</value>
</data>
<data name="bot.autoRelog.wait_with_retries" xml:space="preserve">
<value>Waiting {0:0.000} seconds before reconnecting... ({1} retries left)</value>
</data>
<data name="bot.autoRelog.retries_unlimited" xml:space="preserve">
<value>unlimited</value>
</data>
<data name="bot.autoRespond.file_not_found" xml:space="preserve">
<value>File not found: '{0}'</value>
</data>
@ -830,6 +842,42 @@ Add the ID of this chat to "Authorized_Chat_Ids" field in the configuration file
<data name="botname.TestBot" xml:space="preserve">
<value>TestBot</value>
</data>
<data name="mcc.banner.classic" xml:space="preserve">
<value>Minecraft Console Client v{0} - for MC {1} to {2} - {3}</value>
</data>
<data name="mcc.banner.label_mc_versions" xml:space="preserve">
<value>Supported MC Versions:</value>
</data>
<data name="mcc.server_info.label_server" xml:space="preserve">
<value>Server:</value>
</data>
<data name="mcc.server_info.label_version" xml:space="preserve">
<value>Version:</value>
</data>
<data name="mcc.server_info.label_protocol" xml:space="preserve">
<value>Protocol: {0}</value>
</data>
<data name="mcc.server_info.label_players" xml:space="preserve">
<value>Players:</value>
</data>
<data name="mcc.server_info.label_ping" xml:space="preserve">
<value>Ping:</value>
</data>
<data name="mcc.server_info.label_ping_ms" xml:space="preserve">
<value>{0} ms</value>
</data>
<data name="mcc.server_info.label_connecting_as" xml:space="preserve">
<value>Connecting as:</value>
</data>
<data name="mcc.server_info.label_online" xml:space="preserve">
<value>Online Players:</value>
</data>
<data name="mcc.server_info.sample_more" xml:space="preserve">
<value>... +{0}</value>
</data>
<data name="mcc.server_info.version_upgrade" xml:space="preserve">
<value>Server reported protocol {0} ({1}), upgraded to {2} ({3}) for best compatibility</value>
</data>
<data name="cache.converting" xml:space="preserve">
<value>Converting session cache from disk: {0}</value>
</data>
@ -1249,6 +1297,21 @@ Change EnableEmoji=false in the settings if the display is confusing.</value>
<data name="cmd.effects.none" xml:space="preserve">
<value>No active effects.</value>
</data>
<data name="cmd.tryout.desc" xml:space="preserve">
<value>try a recommended feature.</value>
</data>
<data name="cmd.tryout.list.header" xml:space="preserve">
<value>Available tryouts:</value>
</data>
<data name="cmd.tryout.list.tui" xml:space="preserve">
<value>tui: set [Console.General] ConsoleMode = "tui" for the next restart.</value>
</data>
<data name="cmd.tryout.tui.already_enabled" xml:space="preserve">
<value>[Console.General] ConsoleMode is already "tui" in the config. To switch back, set [Console.General] ConsoleMode = "classic". Restart MCC after changing it for the new mode to take effect.</value>
</data>
<data name="cmd.tryout.tui.enabled" xml:space="preserve">
<value>Updated [Console.General] ConsoleMode from "{0}" to "{1}" in the config. To switch back, set [Console.General] ConsoleMode = "classic". Restart MCC to apply the change.</value>
</data>
<data name="cmd.health.desc" xml:space="preserve">
<value>Display Health and Food saturation.</value>
</data>
@ -1511,6 +1574,21 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
<data name="cmd.tps.desc" xml:space="preserve">
<value>Display server current tps (tick per second). May not be accurate</value>
</data>
<data name="cmd.teams.desc" xml:space="preserve">
<value>List all scoreboard teams and their members.</value>
</data>
<data name="cmd.teams.no_teams" xml:space="preserve">
<value>No teams are currently tracked.</value>
</data>
<data name="cmd.teams.team_header" xml:space="preserve">
<value>Team '{0}' (display: {1}, color: {2}, prefix: '{3}', suffix: '{4}', nameTagVisibility: {5}, collisionRule: {6}, friendlyFire: {7}, seeInvisibles: {8})</value>
</data>
<data name="cmd.teams.team_members" xml:space="preserve">
<value> Members ({0}): {1}</value>
</data>
<data name="cmd.teams.team_no_members" xml:space="preserve">
<value> No members.</value>
</data>
<data name="cmd.useblock.desc" xml:space="preserve">
<value>Place a block or open chest</value>
</data>
@ -1863,6 +1941,18 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
<data name="mcc.connecting" xml:space="preserve">
<value>Connecting to {0}...</value>
</data>
<data name="mcc.console_mode_tui_recommendation" xml:space="preserve">
<value>Tip: try TUI mode for a cleaner interface, mouse-friendly inventory actions, and a nicer layout. Run {0}tryout tui§8 to switch [Console.General] ConsoleMode to "tui" for the next restart.</value>
</data>
<data name="mcc.tui_startup_failed" xml:space="preserve">
<value>MCC encountered a problem while starting TUI mode.</value>
</data>
<data name="mcc.tui_startup_fallback_classic" xml:space="preserve">
<value>As a fallback, MCC has automatically switched [Console.General] ConsoleMode to "classic". This will take effect after you restart MCC.</value>
</data>
<data name="mcc.report_issue" xml:space="preserve">
<value>Please report this issue to the MCC Team.</value>
</data>
<data name="mcc.device_code_prompt" xml:space="preserve">
<value>To sign in, open {0} in your browser and enter the code: §e{1}</value>
</data>
@ -1974,6 +2064,9 @@ Type '{0}quit' to leave the server.</value>
<data name="mcc.restart" xml:space="preserve">
<value>Restarting Minecraft Console Client...</value>
</data>
<data name="mcc.send_text_not_connected" xml:space="preserve">
<value>Cannot send text: not connected to a server.</value>
</data>
<data name="mcc.restart_delay" xml:space="preserve">
<value>Waiting {0} seconds before restarting...</value>
</data>
@ -1988,10 +2081,10 @@ MCC is running with default settings.</value>
<value>Server is in offline mode.</value>
</data>
<data name="mcc.server_protocol" xml:space="preserve">
<value>Server version : {0} (protocol v{1})</value>
<value>Server version: {0} (protocol v{1})</value>
</data>
<data name="mcc.server_version" xml:space="preserve">
<value>Server version : </value>
<value>Server version: </value>
</data>
<data name="mcc.session" xml:space="preserve">
<value>Checking Session...</value>
@ -2151,6 +2244,33 @@ Logging in...</value>
<data name="cmd.nameitem.desc" xml:space="preserve">
<value>Set an item name when an Anvil inventory is active and the item is in the first slot.</value>
</data>
<data name="cmd.recipebook.craft.failed" xml:space="preserve">
<value>Failed to send recipe book craft request for {0}.</value>
</data>
<data name="cmd.recipebook.craft.sent" xml:space="preserve">
<value>Requested recipe {0}.</value>
</data>
<data name="cmd.recipebook.craftall.sent" xml:space="preserve">
<value>Requested recipe {0} with craft-all.</value>
</data>
<data name="cmd.recipebook.desc" xml:space="preserve">
<value>List unlocked recipe book recipes and craft them through the active recipe book inventory.</value>
</data>
<data name="cmd.recipebook.list" xml:space="preserve">
<value>Unlocked recipe book recipes</value>
</data>
<data name="cmd.recipebook.no.active.inventory" xml:space="preserve">
<value>You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory.</value>
</data>
<data name="cmd.recipebook.no.recipes" xml:space="preserve">
<value>No unlocked recipe book recipes are currently tracked.</value>
</data>
<data name="cmd.recipebook.recipe.id.empty" xml:space="preserve">
<value>The recipe identifier cannot be empty.</value>
</data>
<data name="cmd.recipebook.unsupported" xml:space="preserve">
<value>Recipe book crafting is only supported on Minecraft 1.13 and newer.</value>
</data>
<data name="bot.antiafk.may.not.move" xml:space="preserve">
<value>Bot movement lock is held by bot {0}, so the Anti AFK bot might not move!</value>
</data>
@ -2413,4 +2533,94 @@ see item details.</value>
<data name="bot.mcpserver.stop_failed" xml:space="preserve">
<value>Failed to stop embedded MCP server cleanly: {0}</value>
</data>
<data name="cmd.minimap.desc" xml:space="preserve">
<value>Toggle the TUI minimap overlay, or adjust its zoom level.</value>
</data>
<data name="cmd.minimap.enabled" xml:space="preserve">
<value>Minimap enabled.</value>
</data>
<data name="cmd.minimap.disabled" xml:space="preserve">
<value>Minimap disabled.</value>
</data>
<data name="cmd.minimap.zoom_set" xml:space="preserve">
<value>Minimap zoom set to {0}:1 (blocks per pixel).</value>
</data>
<data name="cmd.minimap.zoom_current" xml:space="preserve">
<value>Current minimap zoom: {0}:1 blocks/px (range 1-{1}).</value>
</data>
<data name="cmd.minimap.tui_only" xml:space="preserve">
<value>The minimap command is only available in TUI mode.</value>
</data>
<data name="tui.minimap.legend.hostile" xml:space="preserve">
<value>Hostile</value>
</data>
<data name="tui.minimap.legend.passive" xml:space="preserve">
<value>Passive</value>
</data>
<data name="tui.minimap.legend.neutral" xml:space="preserve">
<value>Neutral</value>
</data>
<data name="tui.minimap.legend.player" xml:space="preserve">
<value>Player</value>
</data>
<data name="cmd.minimap.names_status" xml:space="preserve">
<value>Name display -- Players: {0}, Hostile: {1}, Neutral: {2}, Passive: {3}</value>
</data>
<data name="cmd.minimap.names_all_on" xml:space="preserve">
<value>All entity name labels enabled.</value>
</data>
<data name="cmd.minimap.names_all_off" xml:space="preserve">
<value>All entity name labels disabled.</value>
</data>
<data name="cmd.minimap.names_cat" xml:space="preserve">
<value>{0} name display: {1}</value>
</data>
<data name="cmd.minimap.names_cat_set" xml:space="preserve">
<value>{0} name display set to {1}.</value>
</data>
<data name="cmd.minimap.position_current" xml:space="preserve">
<value>Current minimap position: {0}</value>
</data>
<data name="cmd.minimap.position_set" xml:space="preserve">
<value>Minimap position set to: {0}</value>
</data>
<data name="cmd.minimap.cave_current" xml:space="preserve">
<value>Current cave mode: {0}</value>
</data>
<data name="cmd.minimap.cave_set" xml:space="preserve">
<value>Cave mode set to: {0}</value>
</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>

View file

@ -205,6 +205,29 @@ namespace MinecraftClient.Scripting
/// <param name="entity">Entity with updated location</param>
public virtual void OnEntityMove(Entity entity) { }
/// <summary>
/// Called when a tracked entity receives a velocity update packet.
/// Velocity is expressed in blocks per tick.
/// </summary>
/// <param name="entity">Entity with updated velocity</param>
/// <param name="velocityX">Velocity on X axis (blocks/tick)</param>
/// <param name="velocityY">Velocity on Y axis (blocks/tick)</param>
/// <param name="velocityZ">Velocity on Z axis (blocks/tick)</param>
public virtual void OnEntityVelocity(Entity entity, double velocityX, double velocityY, double velocityZ) { }
/// <summary>
/// Called when a sound packet is received.
/// The sound name is null when the protocol provides only a registry id.
/// </summary>
/// <param name="soundName">Sound key when available, otherwise null</param>
/// <param name="location">Sound position when available</param>
/// <param name="category">Sound category id from packet</param>
/// <param name="volume">Sound volume</param>
/// <param name="pitch">Sound pitch</param>
/// <param name="sourceEntity">Source entity for entity-sound packets when tracked</param>
public virtual void OnSoundEffect(string? soundName, Location? location, int category, float volume, float pitch,
Entity? sourceEntity) { }
/// <summary>
/// Called when an entity rotates
/// </summary>
@ -367,6 +390,23 @@ namespace MinecraftClient.Scripting
/// <param name="numberFormat">Number format: 0 - blank, 1 - styled, 2 - fixed</param>
public virtual void OnUpdateScore(string entityName, int action, string objectiveName, string objectiveDisplayName, int value, int numberFormat) { }
/// <summary>
/// Called when a Teams packet is received from the server.
/// </summary>
/// <param name="teamName">Internal team name (up to 16 chars)</param>
/// <param name="method">0=create, 1=remove, 2=update, 3=add players, 4=remove players</param>
/// <param name="displayName">Display name (formatted). Present when method is 0 or 2.</param>
/// <param name="friendlyFlags">Bit 0=allowFriendlyFire, bit 1=seeFriendlyInvisibles. Present when method is 0 or 2.</param>
/// <param name="nameTagVisibility">Nametag visibility rule. Present when method is 0 or 2.</param>
/// <param name="collisionRule">Collision rule. Present when method is 0 or 2.</param>
/// <param name="color">ChatFormatting color value (-1=none). Present when method is 0 or 2.</param>
/// <param name="prefix">Member name prefix (formatted). Present when method is 0 or 2.</param>
/// <param name="suffix">Member name suffix (formatted). Present when method is 0 or 2.</param>
/// <param name="players">Player/entity names. Present when method is 0, 3, or 4.</param>
public virtual void OnTeam(string teamName, byte method, string displayName, byte friendlyFlags,
string nameTagVisibility, string collisionRule, int color,
string prefix, string suffix, List<string> players) { }
/// <summary>
/// Called when the client received the Tab Header and Footer
/// </summary>
@ -520,6 +560,14 @@ namespace MinecraftClient.Scripting
/// <param name="block">The block</param>
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. */
/* You should not need to interact with other classes of the program. */
@ -1095,9 +1143,10 @@ namespace MinecraftClient.Scripting
/// <param name="direction">Example: if your player is under a block that is being destroyed, use Down</param>
/// <param name="swingArms">Also perform the "arm swing" animation</param>
/// <param name="lookAtBlock">Also look at the block before digging</param>
protected bool DigBlock(Location location, Direction direction, bool swingArms = true, bool lookAtBlock = true)
/// <param name="duration">Dig duration in seconds. 0 = auto-compute for survival, or instant for creative</param>
protected bool DigBlock(Location location, Direction direction, bool swingArms = true, bool lookAtBlock = true, double duration = 0)
{
return Handler.DigBlock(location, direction, swingArms, lookAtBlock);
return Handler.DigBlock(location, direction, swingArms, lookAtBlock, duration);
}
/// <summary>
@ -1126,6 +1175,33 @@ namespace MinecraftClient.Scripting
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>
/// Get all players Latency
/// </summary>

View file

@ -1108,6 +1108,9 @@ namespace MinecraftClient
[TomlPrecedingComment("$Console.CommandSuggestion$")]
public CommandSuggestionConfig CommandSuggestion = new();
[TomlPrecedingComment("$Console.Minimap$")]
public MinimapConfig Minimap = new();
public void OnSettingUpdate()
{
var backend = ConsoleIO.Backend;
@ -1207,11 +1210,17 @@ namespace MinecraftClient
[TomlInlineComment("$Console.General.ConsoleColorMode$")]
public ConsoleColorModeType ConsoleColorMode = ConsoleColorModeType.vt100_24bit;
[TomlInlineComment("$Console.General.Display_Icon_Banner$")]
public bool Display_Icon_Banner = true;
[TomlInlineComment("$Console.General.Display_Input$")]
public bool Display_Input = true;
[TomlInlineComment("$Console.General.History_Input_Records$")]
public int History_Input_Records = 32;
[TomlInlineComment("$Console.General.TUI_Log_Scrollback$")]
public int TUI_Log_Scrollback = 0;
}
[TomlDoNotInlineObject]
@ -1246,6 +1255,53 @@ namespace MinecraftClient
public enum ConsoleModeType { classic, tui };
public enum ConsoleColorModeType { disable, legacy_4bit, vt100_4bit, vt100_8bit, vt100_24bit };
[TomlDoNotInlineObject]
public class MinimapConfig
{
[TomlInlineComment("$Console.Minimap.Enabled$")]
public bool Enabled = false;
[TomlInlineComment("$Console.Minimap.Zoom$")]
public int Zoom = Tui.MinimapControl.DefaultZoom;
[TomlInlineComment("$Console.Minimap.Width$")]
public int Width = Tui.MinimapControl.DefaultWidth;
[TomlInlineComment("$Console.Minimap.Height$")]
public int Height = Tui.MinimapControl.DefaultHeight;
[TomlInlineComment("$Console.Minimap.Position$")]
public Tui.MinimapPosition Position = Tui.MinimapPosition.top_right;
[TomlInlineComment("$Console.Minimap.ShowPlayerNames$")]
public bool ShowPlayerNames = false;
[TomlInlineComment("$Console.Minimap.ShowHostileNames$")]
public bool ShowHostileNames = false;
[TomlInlineComment("$Console.Minimap.ShowNeutralNames$")]
public bool ShowNeutralNames = false;
[TomlInlineComment("$Console.Minimap.ShowPassiveNames$")]
public bool ShowPassiveNames = false;
[TomlInlineComment("$Console.Minimap.RefreshInterval$")]
public int RefreshInterval = Tui.MinimapControl.DefaultRefreshMs;
[TomlInlineComment("$Console.Minimap.CaveMode$")]
public Tui.CaveModeOption CaveMode = Tui.CaveModeOption.auto;
public void OnSettingUpdate()
{
Zoom = Math.Clamp(Zoom, Tui.MinimapControl.MinZoom, Tui.MinimapControl.MaxZoom);
Width = Math.Clamp(Width, 10, 120);
Height = Math.Clamp(Height, 4, 80);
if (Height % 2 != 0) Height++;
RefreshInterval = Math.Clamp(RefreshInterval,
Tui.MinimapControl.MinRefreshMs, Tui.MinimapControl.MaxRefreshMs);
}
}
}
}

View file

@ -0,0 +1,125 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class IconGridBuilder
{
internal static Grid BuildFromRgba(byte[] rgba, int srcWidth, int srcHeight, int displaySize)
{
int cellCols = displaySize;
int cellRows = displaySize / 2;
var grid = new Grid();
for (int c = 0; c < cellCols; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto));
for (int r = 0; r < cellRows; r++)
grid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto));
for (int row = 0; row < cellRows; row++)
{
for (int col = 0; col < cellCols; col++)
{
int topPixelY = row * 2;
int bottomPixelY = row * 2 + 1;
var topColor = SamplePixel(rgba, srcWidth, srcHeight, col, topPixelY, cellCols, displaySize);
var bottomColor = SamplePixel(rgba, srcWidth, srcHeight, col, bottomPixelY, cellCols, displaySize);
var cell = new TextBlock
{
Text = "\u2580",
Foreground = new SolidColorBrush(topColor),
Background = new SolidColorBrush(bottomColor),
Padding = new Thickness(0),
Margin = new Thickness(0),
};
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
grid.Children.Add(cell);
}
}
return grid;
}
internal static Grid BuildFromBase64(string base64Data, int displaySize)
{
byte[] imageBytes;
try
{
imageBytes = Convert.FromBase64String(base64Data);
}
catch
{
return new Grid();
}
return BuildFromImageBytes(imageBytes, displaySize) ?? new Grid();
}
internal static Grid? BuildFromImageBytes(byte[] imageBytes, int displaySize)
{
int srcWidth, srcHeight;
byte[] rgba;
try
{
(srcWidth, srcHeight, rgba) = DecodeImageToRgba(imageBytes);
}
catch
{
return null;
}
return BuildFromRgba(rgba, srcWidth, srcHeight, displaySize);
}
internal static (int Width, int Height, byte[] Rgba) DecodeImageToRgba(byte[] imageData)
{
using var image = new ImageMagick.MagickImage(imageData);
int w = (int)image.Width;
int h = (int)image.Height;
using var pixels = image.GetPixelsUnsafe();
var rgba = new byte[w * h * 4];
for (int y = 0; y < h; y++)
{
for (int x = 0; x < w; x++)
{
var pixel = pixels.GetPixel(x, y)!;
int idx = (y * w + x) * 4;
var color = pixel.ToColor()!;
rgba[idx] = (byte)(color.R >> 8);
rgba[idx + 1] = (byte)(color.G >> 8);
rgba[idx + 2] = (byte)(color.B >> 8);
rgba[idx + 3] = (byte)(color.A >> 8);
}
}
return (w, h, rgba);
}
private static Color SamplePixel(byte[] rgba, int srcW, int srcH, int dstX, int dstY, int dstW, int dstH)
{
int srcX = dstX * srcW / dstW;
int srcY = dstY * srcH / dstH;
srcX = Math.Clamp(srcX, 0, srcW - 1);
srcY = Math.Clamp(srcY, 0, srcH - 1);
int idx = (srcY * srcW + srcX) * 4;
if (idx + 3 >= rgba.Length)
return Color.FromRgb(0, 0, 0);
byte r = rgba[idx];
byte g = rgba[idx + 1];
byte b = rgba[idx + 2];
byte a = rgba[idx + 3];
return a < 128 ? Color.FromRgb(0, 0, 0) : Color.FromRgb(r, g, b);
}
}
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
@ -16,9 +17,20 @@ namespace MinecraftClient.Tui
{
public class MainTuiView : UserControl
{
private const int MaxLogLines = 5000;
private static readonly int MaxLogLines = ResolveMaxLogLines();
private const int CtrlCDoublePressMsec = 1500;
private static int ResolveMaxLogLines()
{
int configured = Settings.Config.Console.General.TUI_Log_Scrollback;
if (configured > 0)
return configured;
bool isArm = RuntimeInformation.ProcessArchitecture
is Architecture.Arm or Architecture.Arm64;
return isArm ? 500 : 3000;
}
private readonly ObservableCollection<string> _logLines = new();
private readonly ObservableCollection<Control> _logControls = new();
private readonly ItemsControl _logItemsControl;
@ -41,6 +53,12 @@ namespace MinecraftClient.Tui
private long _lastLogClickTicks;
private const int DoubleClickMsec = 500;
private readonly Border _minimapBorder;
private readonly MinimapControl _minimapControl;
private volatile bool _minimapVisible;
private TuiTooltipService? _tooltipService;
private readonly Border _suggestionBorder;
private readonly StackPanel _suggestionPanel;
private CommandSuggestion[] _suggestions = Array.Empty<CommandSuggestion>();
@ -53,6 +71,8 @@ namespace MinecraftClient.Tui
private int MaxVisibleSuggestions =>
Math.Max(1, Settings.Config.Console.CommandSuggestion.Max_Displayed_Suggestions);
public TuiTooltipService? TooltipService => _tooltipService;
public MainTuiView()
{
Background = Brushes.Black;
@ -70,6 +90,7 @@ namespace MinecraftClient.Tui
{
ItemsSource = _logControls,
Focusable = false,
ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel()),
};
_logScrollViewer = new ScrollViewer
@ -150,6 +171,30 @@ namespace MinecraftClient.Tui
Margin = new Thickness(0, 0, 0, 1),
};
var mmCfg = Settings.Config.Console.Minimap;
mmCfg.OnSettingUpdate();
_minimapControl = new MinimapControl(mmCfg.Width, mmCfg.Height);
_minimapControl.BlocksPerPixel = mmCfg.Zoom;
_minimapControl.RefreshIntervalMs = mmCfg.RefreshInterval;
_minimapControl.NameConfig.Players = mmCfg.ShowPlayerNames;
_minimapControl.NameConfig.Hostile = mmCfg.ShowHostileNames;
_minimapControl.NameConfig.Neutral = mmCfg.ShowNeutralNames;
_minimapControl.NameConfig.Passive = mmCfg.ShowPassiveNames;
_minimapControl.CaveMode = mmCfg.CaveMode;
var (hAlign, vAlign, margin) = GetMinimapAlignment(mmCfg.Position);
_minimapBorder = new Border
{
Background = new SolidColorBrush(Color.FromArgb(220, 15, 15, 15)),
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Child = _minimapControl,
IsVisible = false,
HorizontalAlignment = hAlign,
VerticalAlignment = vAlign,
Margin = margin,
};
_mainContent = new DockPanel
{
Background = Brushes.Black,
@ -164,11 +209,22 @@ namespace MinecraftClient.Tui
_rootPanel = new Panel
{
Background = Brushes.Black,
Children = { _mainContent, _notificationBorder, _suggestionBorder }
Children = { _mainContent, _minimapBorder, _notificationBorder, _suggestionBorder }
};
_tooltipService = new TuiTooltipService(_rootPanel);
_minimapControl.TooltipService = _tooltipService;
_minimapControl.Position = mmCfg.Position;
Content = _rootPanel;
if (mmCfg.Enabled)
{
_minimapVisible = true;
_minimapBorder.IsVisible = true;
_minimapControl.Start();
}
StartStatusBarTimer();
}
@ -986,6 +1042,104 @@ namespace MinecraftClient.Tui
#endregion
#region Minimap
public void ShowMinimap()
{
if (_minimapVisible) return;
_minimapVisible = true;
_minimapBorder.IsVisible = true;
_minimapControl.Start();
Settings.Config.Console.Minimap.Enabled = true;
}
public void HideMinimap()
{
if (!_minimapVisible) return;
_minimapVisible = false;
_minimapControl.Stop();
_minimapBorder.IsVisible = false;
Settings.Config.Console.Minimap.Enabled = false;
}
public void ToggleMinimap()
{
if (_minimapVisible)
HideMinimap();
else
ShowMinimap();
}
public bool IsMinimapVisible => _minimapVisible;
public void SetMinimapZoom(int level)
{
_minimapControl.BlocksPerPixel = level;
Settings.Config.Console.Minimap.Zoom = level;
}
public int GetMinimapZoom() => _minimapControl.BlocksPerPixel;
public NameDisplayConfig GetMinimapNameConfig() => _minimapControl.NameConfig;
public void SyncMinimapNameConfig()
{
var nc = _minimapControl.NameConfig;
var cfg = Settings.Config.Console.Minimap;
cfg.ShowPlayerNames = nc.Players;
cfg.ShowHostileNames = nc.Hostile;
cfg.ShowNeutralNames = nc.Neutral;
cfg.ShowPassiveNames = nc.Passive;
}
public void ResizeMinimap(int width, int height)
{
_minimapControl.Resize(width, height);
Settings.Config.Console.Minimap.Width = width;
Settings.Config.Console.Minimap.Height = height;
}
public void SetMinimapPosition(MinimapPosition pos)
{
var (hAlign, vAlign, margin) = GetMinimapAlignment(pos);
_minimapBorder.HorizontalAlignment = hAlign;
_minimapBorder.VerticalAlignment = vAlign;
_minimapBorder.Margin = margin;
_minimapControl.Position = pos;
Settings.Config.Console.Minimap.Position = pos;
}
public MinimapPosition GetMinimapPosition() => Settings.Config.Console.Minimap.Position;
public void SetMinimapCaveMode(CaveModeOption mode)
{
_minimapControl.CaveMode = mode;
Settings.Config.Console.Minimap.CaveMode = mode;
}
public CaveModeOption GetMinimapCaveMode() => _minimapControl.CaveMode;
private static (HorizontalAlignment h, VerticalAlignment v, Thickness margin) GetMinimapAlignment(MinimapPosition pos) => pos switch
{
MinimapPosition.top_left => (HorizontalAlignment.Left, VerticalAlignment.Top, new Thickness(1, 1, 0, 0)),
MinimapPosition.top_right => (HorizontalAlignment.Right, VerticalAlignment.Top, new Thickness(0, 1, 1, 0)),
MinimapPosition.center => (HorizontalAlignment.Center, VerticalAlignment.Center, new Thickness(0)),
MinimapPosition.bottom_left => (HorizontalAlignment.Left, VerticalAlignment.Bottom, new Thickness(1, 0, 0, 2)),
MinimapPosition.bottom_right => (HorizontalAlignment.Right, VerticalAlignment.Bottom, new Thickness(0, 0, 1, 2)),
_ => (HorizontalAlignment.Right, VerticalAlignment.Top, new Thickness(0, 1, 1, 0)),
};
public void ApplyMinimapConfig()
{
var cfg = Settings.Config.Console.Minimap;
if (cfg.Enabled && !_minimapVisible)
ShowMinimap();
else if (!cfg.Enabled && _minimapVisible)
HideMinimap();
}
#endregion
#region Overlay
public void ShowOverlay(Control content, Action? onClose = null)
@ -1039,5 +1193,18 @@ namespace MinecraftClient.Tui
_commandInput.Focus();
}, DispatcherPriority.Loaded);
}
#region Custom Control Append
public void AppendControlToLog(Control control)
{
_logLines.Add(string.Empty);
_logControls.Add(control);
TrimLog();
if (_autoScroll)
ScheduleScrollToEnd();
}
#endregion
}
}

View file

@ -52,6 +52,8 @@ namespace MinecraftClient.Tui
IBrush currentColor = Brushes.White;
bool bold = false;
bool italic = false;
bool underline = false;
bool strikethrough = false;
int start = 0;
for (int i = 0; i < text.Length; i++)
@ -59,7 +61,7 @@ namespace MinecraftClient.Tui
if (text[i] == '§' && i + 1 < text.Length)
{
if (i > start)
AddRun(tb, text[start..i], currentColor, bold, italic);
AddRun(tb, text[start..i], currentColor, bold, italic, underline, strikethrough);
char code = char.ToLower(text[i + 1]);
@ -68,6 +70,8 @@ namespace MinecraftClient.Tui
currentColor = brush;
bold = false;
italic = false;
underline = false;
strikethrough = false;
}
else
{
@ -75,10 +79,14 @@ namespace MinecraftClient.Tui
{
case 'l': bold = true; break;
case 'o': italic = true; break;
case 'n': underline = true; break;
case 'm': strikethrough = true; break;
case 'r':
currentColor = Brushes.White;
bold = false;
italic = false;
underline = false;
strikethrough = false;
break;
}
}
@ -89,7 +97,7 @@ namespace MinecraftClient.Tui
}
if (start < text.Length)
AddRun(tb, text[start..], currentColor, bold, italic);
AddRun(tb, text[start..], currentColor, bold, italic, underline, strikethrough);
if (tb.Inlines?.Count == 0)
{
@ -100,16 +108,29 @@ namespace MinecraftClient.Tui
return tb;
}
private static void AddRun(TextBlock tb, string text, IBrush color, bool bold, bool italic)
private static void AddRun(TextBlock tb, string text, IBrush color,
bool bold, bool italic, bool underline, bool strikethrough)
{
if (text.Length == 0) return;
tb.Inlines ??= new InlineCollection();
TextDecorationCollection? decorations = null;
if (underline || strikethrough)
{
decorations = [];
if (underline)
decorations.Add(new TextDecoration { Location = TextDecorationLocation.Underline });
if (strikethrough)
decorations.Add(new TextDecoration { Location = TextDecorationLocation.Strikethrough });
}
tb.Inlines.Add(new Run(text)
{
Foreground = color,
FontWeight = bold ? FontWeight.Bold : FontWeight.Normal,
FontStyle = italic ? FontStyle.Italic : FontStyle.Normal,
TextDecorations = decorations,
});
}
}

View file

@ -0,0 +1,178 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class MccBannerPanelBuilder
{
internal static Border Build(string? buildInfo)
{
var contentPanel = new DockPanel { Background = Brushes.Black };
var icon = BuildIcon();
icon.VerticalAlignment = VerticalAlignment.Center;
DockPanel.SetDock(icon, Dock.Left);
contentPanel.Children.Add(icon);
var infoPanel = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(1, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
AddTitle(infoPanel);
AddVersionRange(infoPanel);
AddGithub(infoPanel);
if (buildInfo is not null)
AddBuildInfo(infoPanel, buildInfo);
contentPanel.Children.Add(infoPanel);
return new Border
{
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(240, 20, 20, 20)),
Padding = new Thickness(1, 0),
Child = contentPanel,
Margin = new Thickness(0),
};
}
private static void AddTitle(StackPanel panel)
{
var row = new TextBlock();
row.Inlines!.Add(new Run("Minecraft Console Client")
{ Foreground = Pal.Gold, FontWeight = FontWeight.Bold });
row.Inlines.Add(new Run($" v{Program.Version}") { Foreground = Pal.Aqua });
panel.Children.Add(row);
}
private static void AddVersionRange(StackPanel panel)
{
var row = new TextBlock();
row.Inlines!.Add(Lbl(Translations.mcc_banner_label_mc_versions));
row.Inlines.Add(Val(Program.MCLowestVersion, Pal.Green));
row.Inlines.Add(new Run(" - ") { Foreground = Pal.Gray });
row.Inlines.Add(Val(Program.MCHighestVersion, Pal.Green));
panel.Children.Add(row);
}
private static void AddGithub(StackPanel panel)
{
var row = new TextBlock();
row.Inlines!.Add(Val("Github.com/MCCTeam", Pal.Gray));
panel.Children.Add(row);
}
private static void AddBuildInfo(StackPanel panel, string buildInfo)
{
panel.Children.Add(new TextBlock
{
Text = buildInfo,
Foreground = Pal.DarkGray,
});
}
#region Icon
private static readonly Color B1 = Color.FromRgb(200, 200, 200); // bezel bright
private static readonly Color B2 = Color.FromRgb(160, 160, 160); // bezel mid
private static readonly Color B3 = Color.FromRgb(120, 120, 120); // bezel dark
private static readonly Color S = Color.FromRgb(20, 20, 20); // screen bg
private static readonly Color C = Color.FromRgb(55, 200, 55); // creeper green
// @formatter:off
private static readonly Color[,] Pixels =
{
{ B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B1, B2 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, C, C, S, S, C, C, S, B3 },
{ B1, S, S, S, S, S, S, S, S, C, C, S, S, C, C, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, C, C, S, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, C, C, C, C, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, C, C, C, C, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, C, S, S, C, S, S, B3 },
{ B1, S, S, S, S, S, S, S, S, S, S, S, S, S, S, S, B3 },
{ B2, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3, B3 },
};
// @formatter:on
private static Control BuildIcon()
{
int cols = Pixels.GetLength(1);
int textRows = Pixels.GetLength(0) / 2;
var pixelGrid = new Grid();
for (int c = 0; c < cols; c++)
pixelGrid.ColumnDefinitions.Add(new ColumnDefinition(1, GridUnitType.Auto));
for (int r = 0; r < textRows; r++)
pixelGrid.RowDefinitions.Add(new RowDefinition(1, GridUnitType.Auto));
for (int row = 0; row < textRows; row++)
{
for (int col = 0; col < cols; col++)
{
var topColor = Pixels[row * 2, col];
var bottomColor = Pixels[row * 2 + 1, col];
var cell = new TextBlock
{
Text = "\u2580",
Foreground = new SolidColorBrush(topColor),
Background = new SolidColorBrush(bottomColor),
Padding = new Thickness(0),
Margin = new Thickness(0),
};
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
pixelGrid.Children.Add(cell);
}
}
var prompt = new TextBlock
{
Text = " _",
Foreground = new SolidColorBrush(Color.FromRgb(255, 255, 255)),
Background = new SolidColorBrush(S),
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
};
Grid.SetRow(prompt, 1);
Grid.SetColumn(prompt, 1);
Grid.SetColumnSpan(prompt, 4);
pixelGrid.Children.Add(prompt);
return pixelGrid;
}
#endregion
private static Run Lbl(string text) =>
new(text + " ") { Foreground = Pal.Gray };
private static Run Val(string text, IBrush color) =>
new(text) { Foreground = color };
private static class Pal
{
public static readonly IBrush Gray = new SolidColorBrush(Color.FromRgb(170, 170, 170));
public static readonly IBrush DarkGray = new SolidColorBrush(Color.FromRgb(85, 85, 85));
public static readonly IBrush Aqua = new SolidColorBrush(Color.FromRgb(85, 255, 255));
public static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(85, 255, 85));
public static readonly IBrush Gold = new SolidColorBrush(Color.FromRgb(255, 170, 0));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,191 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json;
using Avalonia.Media;
using MinecraftClient.Mapping;
namespace MinecraftClient.Tui
{
/// <summary>
/// Maps block Materials to minimap colors using data extracted from Minecraft's
/// official MapColor table. Colors are loaded from the embedded MinimapBlockColors.json
/// resource generated by tools/gen_block_color_map.py.
/// </summary>
public static class MinimapColorMap
{
public static readonly Color WaterColor = Color.FromRgb(64, 64, 255);
public static readonly Color IceColor = Color.FromRgb(160, 160, 255);
public static readonly Color LavaColor = Color.FromRgb(255, 100, 0);
public static readonly Color DefaultColor = Color.FromRgb(60, 60, 60);
public static readonly Color VoidColor = Color.FromRgb(0, 0, 0);
public static readonly Color CaveBorderColor = Color.FromRgb(16, 16, 16);
public static readonly Color CaveSolidColor = Color.FromRgb(24, 20, 18);
private static readonly FrozenDictionary<Material, Color> ColorTable;
private static readonly FrozenSet<Material> FullyTransparentMats;
private static readonly FrozenSet<Material> WaterMats;
private static readonly FrozenSet<Material> IceMats;
static MinimapColorMap()
{
var colors = new Dictionary<Material, Color>();
var transparent = new HashSet<Material>();
var water = new HashSet<Material>();
var ice = new HashSet<Material>();
try
{
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MinimapBlockColors.json");
if (stream is not null)
{
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
if (root.TryGetProperty("colors", out var colorsEl))
{
foreach (var prop in colorsEl.EnumerateObject())
{
if (!Enum.TryParse<Material>(prop.Name, out var mat))
continue;
var arr = prop.Value;
if (arr.GetArrayLength() < 3) continue;
byte r = (byte)arr[0].GetInt32();
byte g = (byte)arr[1].GetInt32();
byte b = (byte)arr[2].GetInt32();
colors[mat] = Color.FromRgb(r, g, b);
}
}
if (root.TryGetProperty("transparent", out var transEl))
{
foreach (var item in transEl.EnumerateArray())
{
if (Enum.TryParse<Material>(item.GetString(), out var mat))
transparent.Add(mat);
}
}
if (root.TryGetProperty("water", out var waterEl))
{
foreach (var item in waterEl.EnumerateArray())
{
if (Enum.TryParse<Material>(item.GetString(), out var mat))
water.Add(mat);
}
}
if (root.TryGetProperty("ice", out var iceEl))
{
foreach (var item in iceEl.EnumerateArray())
{
if (Enum.TryParse<Material>(item.GetString(), out var mat))
ice.Add(mat);
}
}
}
}
catch (Exception ex)
{
ConsoleIO.WriteLineFormatted($"\u00a7e[Minimap] Failed to load color data: {ex.Message}");
}
if (transparent.Count == 0)
{
transparent.Add(Material.Air);
transparent.Add(Material.CaveAir);
transparent.Add(Material.VoidAir);
}
if (water.Count == 0)
water.Add(Material.Water);
if (ice.Count == 0)
{
ice.Add(Material.Ice);
ice.Add(Material.PackedIce);
ice.Add(Material.BlueIce);
ice.Add(Material.FrostedIce);
}
ColorTable = colors.ToFrozenDictionary();
FullyTransparentMats = transparent.ToFrozenSet();
WaterMats = water.ToFrozenSet();
IceMats = ice.ToFrozenSet();
}
public static bool IsFullyTransparent(Material m) => FullyTransparentMats.Contains(m);
/// <summary>
/// Returns true for materials that block light propagation (solid, liquids),
/// used by cave mode to find the surface from the player's Y level.
/// Mirrors VoxelMap's lightDampening > 0 check.
/// </summary>
public static bool IsLightBlocking(Material m)
=> (m == Material.Lava) || (!FullyTransparentMats.Contains(m) && m.IsSolid());
public static bool IsWater(Material m) => WaterMats.Contains(m);
public static bool IsIce(Material m) => IceMats.Contains(m);
public static Color GetBaseColor(Material m)
{
if (m == Material.Lava)
return LavaColor;
return ColorTable.GetValueOrDefault(m, DefaultColor);
}
/// <summary>
/// Apply Minecraft-style height shading. The shade multiplier depends on
/// the height difference between the current block and the block to its north.
/// Vanilla maps use four brightness levels: LOW (180/255), NORMAL (220/255),
/// HIGH (255/255), and LOWEST (135/255). We use NORMAL as baseline and shift
/// up/down based on delta.
/// </summary>
public static Color ApplyHeightShade(Color baseColor, int heightDelta)
{
int multiplier = heightDelta switch
{
> 0 => 255, // higher than neighbor: brightest
0 => 220, // same height: normal
_ => 180, // lower than neighbor: darker
};
byte r = (byte)(baseColor.R * multiplier / 255);
byte g = (byte)(baseColor.G * multiplier / 255);
byte b = (byte)(baseColor.B * multiplier / 255);
return Color.FromRgb(r, g, b);
}
public static Color BlendWaterColor(Color bottomColor, int waterDepth)
{
double alpha = Math.Min(0.85, 0.35 + waterDepth * 0.08);
return Blend(WaterColor, bottomColor, alpha);
}
public static Color BlendIceColor(Color bottomColor)
{
return Blend(IceColor, bottomColor, 0.35);
}
/// <summary>
/// Darken a color to simulate underground lighting. Cave floors receive
/// a minimum brightness of ~32/255 for non-solid blocks (matching VoxelMap),
/// while solid/unreachable columns render as near-black.
/// </summary>
public static Color ApplyCaveDarkening(Color baseColor, double factor = 0.55)
{
byte r = (byte)(baseColor.R * factor);
byte g = (byte)(baseColor.G * factor);
byte b = (byte)(baseColor.B * factor);
return Color.FromRgb(r, g, b);
}
private static Color Blend(Color top, Color bottom, double topAlpha)
{
byte r = (byte)(top.R * topAlpha + bottom.R * (1.0 - topAlpha));
byte g = (byte)(top.G * topAlpha + bottom.G * (1.0 - topAlpha));
byte b = (byte)(top.B * topAlpha + bottom.B * (1.0 - topAlpha));
return Color.FromRgb(r, g, b);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,167 @@
{
"version": "26.1-rc-2",
"hostile": [
"Blaze",
"Bogged",
"Breeze",
"CamelHusk",
"Creaking",
"Creeper",
"Drowned",
"ElderGuardian",
"EnderDragon",
"Endermite",
"Evoker",
"Ghast",
"Giant",
"Guardian",
"Hoglin",
"Husk",
"Illusioner",
"MagmaCube",
"Parched",
"Phantom",
"Piglin",
"PiglinBrute",
"Pillager",
"Ravager",
"Shulker",
"Silverfish",
"Skeleton",
"Slime",
"Stray",
"Vex",
"Vindicator",
"Warden",
"Witch",
"Wither",
"WitherSkeleton",
"Zoglin",
"Zombie",
"ZombieNautilus",
"ZombieVillager"
],
"passive": [
"Allay",
"Armadillo",
"Axolotl",
"Bat",
"Camel",
"Cat",
"Chicken",
"Cod",
"Cow",
"Donkey",
"Fox",
"Frog",
"GlowSquid",
"HappyGhast",
"Horse",
"Mooshroom",
"Mule",
"Nautilus",
"Ocelot",
"Parrot",
"Pig",
"Pufferfish",
"Rabbit",
"Salmon",
"Sheep",
"SkeletonHorse",
"Sniffer",
"Squid",
"Strider",
"Tadpole",
"TropicalFish",
"Turtle",
"Villager",
"WanderingTrader",
"ZombieHorse"
],
"neutral": [
"Bee",
"CaveSpider",
"CopperGolem",
"Dolphin",
"Enderman",
"Goat",
"IronGolem",
"Llama",
"Panda",
"PolarBear",
"SnowGolem",
"Spider",
"TraderLlama",
"Wolf",
"ZombifiedPiglin"
],
"non_living": [
"AcaciaBoat",
"AcaciaChestBoat",
"AreaEffectCloud",
"ArmorStand",
"Arrow",
"BambooChestRaft",
"BambooRaft",
"BirchBoat",
"BirchChestBoat",
"BlockDisplay",
"BreezeWindCharge",
"CherryBoat",
"CherryChestBoat",
"ChestMinecart",
"CommandBlockMinecart",
"DarkOakBoat",
"DarkOakChestBoat",
"DragonFireball",
"Egg",
"EndCrystal",
"EnderPearl",
"EvokerFangs",
"ExperienceBottle",
"ExperienceOrb",
"EyeOfEnder",
"FallingBlock",
"Fireball",
"FireworkRocket",
"FishingBobber",
"FurnaceMinecart",
"GlowItemFrame",
"HopperMinecart",
"Interaction",
"Item",
"ItemDisplay",
"ItemFrame",
"JungleBoat",
"JungleChestBoat",
"LeashKnot",
"LightningBolt",
"LingeringPotion",
"LlamaSpit",
"MangroveBoat",
"MangroveChestBoat",
"Mannequin",
"Marker",
"Minecart",
"OakBoat",
"OakChestBoat",
"OminousItemSpawner",
"Painting",
"PaleOakBoat",
"PaleOakChestBoat",
"ShulkerBullet",
"SmallFireball",
"Snowball",
"SpawnerMinecart",
"SpectralArrow",
"SplashPotion",
"SpruceBoat",
"SpruceChestBoat",
"TextDisplay",
"Tnt",
"TntMinecart",
"Trident",
"WindCharge",
"WitherSkull"
]
}

View file

@ -0,0 +1,178 @@
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Reflection;
using System.Text.Json;
using Avalonia.Media;
using MinecraftClient.Mapping;
namespace MinecraftClient.Tui
{
public enum MobCategory
{
Hostile,
Passive,
Neutral,
Player,
NonLiving,
}
public enum MinimapPosition
{
top_left,
top_right,
center,
bottom_left,
bottom_right,
}
public sealed class NameDisplayConfig
{
public volatile bool Players = false;
public volatile bool Hostile = false;
public volatile bool Neutral = false;
public volatile bool Passive = false;
public bool AnyEnabled => Players || Hostile || Neutral || Passive;
public void SetAll(bool value)
{
Players = value;
Hostile = value;
Neutral = value;
Passive = value;
}
public bool ShouldShowName(MobCategory category) => category switch
{
MobCategory.Player => Players,
MobCategory.Hostile => Hostile,
MobCategory.Neutral => Neutral,
MobCategory.Passive => Passive,
_ => false,
};
}
/// <summary>
/// Classifies entities into minimap categories using data extracted from
/// Minecraft's MobCategory assignments. Categories are loaded from the
/// embedded MinimapEntityCategories.json resource generated by
/// tools/gen_entity_category_map.py.
/// </summary>
public static class MinimapEntityClassifier
{
public static readonly Color HostileColor = Color.FromRgb(255, 68, 68);
public static readonly Color PassiveColor = Color.FromRgb(68, 255, 68);
public static readonly Color NeutralColor = Color.FromRgb(255, 170, 0);
public static readonly Color PlayerColor = Color.FromRgb(255, 255, 255);
public static readonly Color FadedGray = Color.FromRgb(100, 100, 100);
private static readonly FrozenDictionary<EntityType, MobCategory> CategoryTable;
static MinimapEntityClassifier()
{
var table = new Dictionary<EntityType, MobCategory>();
try
{
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("MinimapEntityCategories.json");
if (stream is not null)
{
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
LoadCategory(root, "hostile", MobCategory.Hostile, table);
LoadCategory(root, "passive", MobCategory.Passive, table);
LoadCategory(root, "neutral", MobCategory.Neutral, table);
LoadCategory(root, "non_living", MobCategory.NonLiving, table);
}
}
catch (Exception ex)
{
ConsoleIO.WriteLogLine($"[Minimap] Failed to load entity categories: {ex.Message}");
}
CategoryTable = table.ToFrozenDictionary();
}
private static void LoadCategory(JsonElement root, string key,
MobCategory category, Dictionary<EntityType, MobCategory> table)
{
if (!root.TryGetProperty(key, out var arr))
return;
foreach (var el in arr.EnumerateArray())
{
var name = el.GetString();
if (name is not null && Enum.TryParse<EntityType>(name, out var et))
table.TryAdd(et, category);
}
}
public static MobCategory Classify(EntityType type)
{
if (type == EntityType.Player)
return MobCategory.Player;
return CategoryTable.GetValueOrDefault(type, MobCategory.NonLiving);
}
public static Color GetBaseColor(MobCategory category) => category switch
{
MobCategory.Hostile => HostileColor,
MobCategory.Passive => PassiveColor,
MobCategory.Neutral => NeutralColor,
MobCategory.Player => PlayerColor,
_ => FadedGray,
};
public static Color ApplyDepthFade(Color baseColor, double playerY, double entityY)
{
double depth = playerY - entityY;
if (depth <= 5.0)
return baseColor;
if (depth >= 15.0)
return FadedGray;
double t = (depth - 5.0) / 10.0;
return Lerp(baseColor, FadedGray, t);
}
public static bool ShouldDisplay(MobCategory category, double playerY, double entityY)
{
if (category == MobCategory.Player)
return true;
if (entityY >= playerY)
return true;
return playerY - entityY <= 15.0;
}
public static int GetPriority(MobCategory category) => category switch
{
MobCategory.Hostile => 4,
MobCategory.Player => 3,
MobCategory.Neutral => 2,
MobCategory.Passive => 1,
_ => 0,
};
public static string GetCategoryLabel(MobCategory category) => category switch
{
MobCategory.Hostile => Translations.tui_minimap_legend_hostile,
MobCategory.Passive => Translations.tui_minimap_legend_passive,
MobCategory.Neutral => Translations.tui_minimap_legend_neutral,
MobCategory.Player => Translations.tui_minimap_legend_player,
_ => "?",
};
private static Color Lerp(Color a, Color b, double t)
{
byte r = (byte)(a.R + (b.R - a.R) * t);
byte g = (byte)(a.G + (b.G - a.G) * t);
byte bl = (byte)(a.B + (b.B - a.B) * t);
return Color.FromRgb(r, g, bl);
}
}
}

View file

@ -0,0 +1,196 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Layout;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
internal static class ServerStatusPanelBuilder
{
private const int MaxSamplePlayers = 10;
private const int FaviconDisplaySize = 16;
internal static Border Build(Protocol.ServerStatusInfo info)
{
var contentPanel = new DockPanel { Background = Brushes.Black };
if (info.FaviconBase64 is not null)
{
var iconGrid = BuildFaviconGrid(info.FaviconBase64, FaviconDisplaySize);
iconGrid.VerticalAlignment = VerticalAlignment.Center;
DockPanel.SetDock(iconGrid, Dock.Left);
contentPanel.Children.Add(iconGrid);
}
var infoPanel = new StackPanel
{
Orientation = Orientation.Vertical,
Margin = new Thickness(1, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
AddMotd(infoPanel, info);
AddAddress(infoPanel, info);
AddVersion(infoPanel, info);
AddConnectingAs(infoPanel, info);
AddPing(infoPanel, info);
AddPlayers(infoPanel, info);
AddSamplePlayers(infoPanel, info);
contentPanel.Children.Add(infoPanel);
return new Border
{
BorderBrush = new SolidColorBrush(Color.FromRgb(80, 80, 80)),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Color.FromArgb(240, 20, 20, 20)),
Padding = new Thickness(1, 0),
Child = contentPanel,
Margin = new Thickness(0),
};
}
private static void AddMotd(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (string.IsNullOrEmpty(info.MotdRaw))
return;
try
{
string motdFormatted = Protocol.Message.ChatParser.ParseText(info.MotdRaw);
foreach (string line in motdFormatted.Split('\n'))
panel.Children.Add(McColorParser.CreateColoredTextBlock(line, TextWrapping.NoWrap));
}
catch
{
panel.Children.Add(new TextBlock
{
Text = info.MotdRaw,
Foreground = Brushes.White,
TextWrapping = TextWrapping.NoWrap,
});
}
}
private static void AddAddress(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_server));
row.Inlines.Add(Value(info.Host, McColors.Aqua));
row.Inlines.Add(new Run($":{info.Port}") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddVersion(StackPanel panel, Protocol.ServerStatusInfo info)
{
string versionClean = Scripting.ChatBot.GetVerbatim(info.VersionName);
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_version));
row.Inlines.Add(Value(versionClean, McColors.Aqua));
row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray });
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ProtocolVersion))
{ Foreground = McColors.Gray });
row.Inlines.Add(new Run(")") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddConnectingAs(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.ResolvedProtocol == 0)
return;
string resolvedMcVer = Protocol.ProtocolHandler.ProtocolVersion2MCVer(info.ResolvedProtocol);
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_connecting_as));
row.Inlines.Add(Value(resolvedMcVer, McColors.Green));
row.Inlines.Add(new Run(" (") { Foreground = McColors.Gray });
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_protocol, info.ResolvedProtocol))
{ Foreground = McColors.Gray });
row.Inlines.Add(new Run(")") { Foreground = McColors.Gray });
panel.Children.Add(row);
}
private static void AddPing(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.PingMs < 0)
return;
var pingColor = info.PingMs < 100
? McColors.Green
: info.PingMs < 300
? McColors.Yellow
: McColors.Red;
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_ping));
row.Inlines.Add(new Run(string.Format(Translations.mcc_server_info_label_ping_ms, info.PingMs))
{ Foreground = pingColor });
panel.Children.Add(row);
}
private static void AddPlayers(StackPanel panel, Protocol.ServerStatusInfo info)
{
var row = new TextBlock();
row.Inlines!.Add(Label(Translations.mcc_server_info_label_players));
row.Inlines.Add(Value($"{info.OnlinePlayers}", McColors.Green));
row.Inlines.Add(new Run("/") { Foreground = McColors.Gray });
row.Inlines.Add(Value($"{info.MaxPlayers}", McColors.Red));
panel.Children.Add(row);
}
private static void AddSamplePlayers(StackPanel panel, Protocol.ServerStatusInfo info)
{
if (info.SamplePlayers.Count == 0)
return;
panel.Children.Add(new TextBlock
{
Text = Translations.mcc_server_info_label_online,
Foreground = McColors.Gray,
});
int shown = Math.Min(info.SamplePlayers.Count, MaxSamplePlayers);
for (int i = 0; i < shown; i++)
{
string name = info.SamplePlayers[i].Name;
if (name.Contains('\u00a7'))
panel.Children.Add(McColorParser.CreateColoredTextBlock($" {name}", TextWrapping.NoWrap));
else
panel.Children.Add(new TextBlock
{
Text = $" {name}",
Foreground = McColors.Green,
});
}
if (info.SamplePlayers.Count > shown)
{
panel.Children.Add(new TextBlock
{
Text = $" {string.Format(Translations.mcc_server_info_sample_more, info.SamplePlayers.Count - shown)}",
Foreground = McColors.Gray,
});
}
}
private static Run Label(string text) =>
new(text + " ") { Foreground = McColors.Gray };
private static Run Value(string text, IBrush color) =>
new(text) { Foreground = color };
private static Grid BuildFaviconGrid(string base64Png, int displaySize) =>
IconGridBuilder.BuildFromBase64(base64Png, displaySize);
private static class McColors
{
public static readonly IBrush Gray = new SolidColorBrush(Color.FromRgb(170, 170, 170));
public static readonly IBrush Aqua = new SolidColorBrush(Color.FromRgb(85, 255, 255));
public static readonly IBrush Green = new SolidColorBrush(Color.FromRgb(85, 255, 85));
public static readonly IBrush Red = new SolidColorBrush(Color.FromRgb(255, 85, 85));
public static readonly IBrush Yellow = new SolidColorBrush(Color.FromRgb(255, 255, 85));
}
}
}

View file

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace MinecraftClient.Tui
{
public sealed class TuiTooltipLine
{
public string Text { get; init; } = "";
public IBrush Foreground { get; init; } = Brushes.White;
}
/// <summary>
/// Global tooltip that floats above all TUI content.
/// Owned by MainTuiView, used by minimap / chat / other components.
/// </summary>
public sealed class TuiTooltipService
{
private readonly Panel _rootPanel;
private readonly Canvas _canvas;
private readonly Border _border;
private readonly StackPanel _content;
internal TuiTooltipService(Panel rootPanel)
{
_content = new StackPanel { Orientation = Avalonia.Layout.Orientation.Vertical };
_border = new Border
{
Background = new SolidColorBrush(Color.FromArgb(230, 20, 20, 20)),
BorderBrush = new SolidColorBrush(Color.FromRgb(120, 120, 120)),
BorderThickness = new Thickness(1),
Padding = new Thickness(1),
Child = _content,
IsVisible = false,
};
_canvas = new Canvas
{
IsHitTestVisible = false,
Children = { _border },
};
_rootPanel = rootPanel;
rootPanel.Children.Add(_canvas);
}
/// <param name="mouseX">Global X of the mouse cursor.</param>
/// <param name="mouseY">Global Y of the mouse cursor.</param>
/// <param name="preferRight">
/// If true, try placing tooltip to the right of mouseX;
/// if false, try placing to the left.
/// The service auto-flips when the tooltip would overflow the screen.
/// </param>
public void Show(double mouseX, double mouseY, IReadOnlyList<TuiTooltipLine> lines,
bool preferRight = true)
{
_content.Children.Clear();
if (lines.Count == 0)
{
_border.IsVisible = false;
return;
}
int maxChars = 0;
foreach (var line in lines)
{
_content.Children.Add(new TextBlock
{
Text = line.Text,
Foreground = line.Foreground,
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(0),
Margin = new Thickness(0),
FontSize = 1,
});
if (line.Text.Length > maxChars)
maxChars = line.Text.Length;
}
double tipW = maxChars + 4;
double screenW = _rootPanel.Bounds.Width;
const double gap = 1;
double gx;
if (preferRight)
{
gx = mouseX + gap;
if (gx + tipW > screenW)
gx = mouseX - tipW - gap;
}
else
{
gx = mouseX - tipW - gap;
if (gx < 0)
gx = mouseX + gap;
}
Canvas.SetLeft(_border, Math.Max(0, gx));
Canvas.SetTop(_border, Math.Max(0, mouseY));
_border.IsVisible = true;
}
public void Hide()
{
_border.IsVisible = false;
_content.Children.Clear();
}
public bool IsVisible => _border.IsVisible;
}
}

View file

@ -4,8 +4,6 @@ MCC.LoadBot(new PacketCadenceCaptureBot());
//MCCScript Extensions
using System.Threading;
public class PacketCadenceCaptureBot : ChatBot
{
private const int CaptureDurationSeconds = 5;

View file

@ -26,7 +26,25 @@
## Download
Get development builds from the [Releases section](https://github.com/MCCTeam/Minecraft-Console-Client/releases/latest)
Get the latest release from the [Releases section](https://github.com/MCCTeam/Minecraft-Console-Client/releases/latest).
## Quick Install ⚡
Open a terminal in the folder where you want MCC and run:
Linux / macOS:
```bash
curl -fsSL https://mccteam.github.io/install.sh | sh
```
Windows (PowerShell):
```powershell
iwr -useb https://mccteam.github.io/install.ps1 | iex
```
The script detects your architecture and downloads the right binary. For more options (including `wget` and manual downloads), see the [installation guide](https://mccteam.github.io/guide/installation.html).
## How to use 📚

View file

@ -1,6 +1,6 @@
"project_id_env": "CROWDIN_PROJECT_ID"
"api_token_env": "CROWDIN_PERSONAL_TOKEN"
"base_path": "/"
"base_path": "./"
"preserve_hierarchy": true
"base_url": "https://api.crowdin.com"

View file

@ -0,0 +1,85 @@
# Minecraft Console Client - Installer for Windows
# Downloads the latest MinecraftClient binary for your Windows architecture.
# Usage (PowerShell): iwr -useb https://mccteam.github.io/install.ps1 | iex
$ErrorActionPreference = 'Stop'
$REPO = "MCCTeam/Minecraft-Console-Client"
$OUTPUT = "MinecraftClient.exe"
# --- Detect CPU architecture ---
$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture
$archId = switch ($arch) {
'X64' { 'x64' }
'X86' { 'x86' }
'Arm64' { 'arm64' }
default {
Write-Error "Unsupported CPU architecture: $arch"
exit 1
}
}
$suffix = "win-$archId"
# --- Fetch latest release metadata from GitHub API ---
$apiUrl = "https://api.github.com/repos/$REPO/releases/latest"
Write-Host "Fetching latest release information..."
$release = Invoke-RestMethod -Uri $apiUrl -UseBasicParsing
# --- Locate the correct asset ---
$asset = $release.assets | Where-Object { $_.name -match "^MinecraftClient-.*-$([regex]::Escape($suffix))\.exe$" } | Select-Object -First 1
if (-not $asset) {
Write-Error "Could not find a release asset for '$suffix'."
exit 1
}
$downloadUrl = $asset.browser_download_url
$tag = $release.tag_name
Write-Host "Downloading MinecraftClient $tag ($suffix)..."
# Download with a built-in ASCII progress bar (no external tools required).
# HttpWebRequest streams the body on the main thread so we can update the
# progress bar inline without any Runspace or thread-safety concerns.
$outPath = Join-Path (Get-Location).Path $OUTPUT
$request = [System.Net.HttpWebRequest]::Create($downloadUrl)
$response = $request.GetResponse()
$totalBytes = $response.ContentLength
$responseStream = $response.GetResponseStream()
$fileStream = [System.IO.File]::Create($outPath)
$buffer = New-Object byte[] 32768
$totalRead = 0
try {
while ($true) {
$read = $responseStream.Read($buffer, 0, $buffer.Length)
if ($read -le 0) { break }
$fileStream.Write($buffer, 0, $read)
$totalRead += $read
if ($totalBytes -gt 0) {
$pct = [int]($totalRead * 100 / $totalBytes)
$filled = '=' * [int]($pct / 2)
$bar = $filled.PadRight(50)
$recv = [math]::Round($totalRead / 1MB, 1)
$total = [math]::Round($totalBytes / 1MB, 1)
# Use [Console]::Write with an explicit \r so the cursor returns to
# column 0 and overwrites the previous bar. Write-Host -NoNewline
# does not reliably reposition the cursor when the script is run
# via iex (pipe mode), producing multiple bars on one line.
$line = "`r[{0}] {1,3}% {2,6:N1} / {3,6:N1} MB" -f $bar, $pct, $recv, $total
[Console]::Write($line)
}
}
} finally {
$fileStream.Close()
$responseStream.Close()
$response.Close()
}
[Console]::WriteLine() # end the progress line
Write-Host ""
Write-Host "Downloaded: .\$OUTPUT"
Write-Host "Run with: .\$OUTPUT --help"

View file

@ -0,0 +1,106 @@
#!/bin/sh
# Minecraft Console Client - Installer
# Downloads the latest MinecraftClient binary for your Linux or macOS platform.
# Usage: curl -fsSL https://mccteam.github.io/install.sh | sh
# or: wget -qO- https://mccteam.github.io/install.sh | sh
set -e
REPO="MCCTeam/Minecraft-Console-Client"
OUTPUT="MinecraftClient"
# --- Detect OS ---
OS=$(uname -s)
case "$OS" in
Linux) PLATFORM="linux" ;;
Darwin) PLATFORM="osx" ;;
*)
echo "Error: Unsupported OS '$OS'. This script supports Linux and macOS." >&2
exit 1
;;
esac
# --- Detect CPU architecture ---
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ARCH_ID="x64" ;;
aarch64|arm64) ARCH_ID="arm64" ;;
armv7l|armv8l|armhf) ARCH_ID="arm" ;;
arm*) ARCH_ID="arm" ;;
*)
echo "Error: Unsupported CPU architecture '$ARCH'." >&2
exit 1
;;
esac
# macOS does not have an arm (32-bit) build
if [ "$PLATFORM" = "osx" ] && [ "$ARCH_ID" = "arm" ]; then
echo "Error: 32-bit ARM is not supported on macOS." >&2
exit 1
fi
SUFFIX="${PLATFORM}-${ARCH_ID}"
# --- Download helpers: prefer curl, fall back to wget ---
_download_stdout() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$1"
else
echo "Error: Neither 'curl' nor 'wget' is available. Please install one and retry." >&2
exit 1
fi
}
_download_file() {
if command -v curl >/dev/null 2>&1; then
curl -fL --progress-bar -o "$2" "$1"
elif command -v wget >/dev/null 2>&1; then
# --show-progress forces the progress bar even when stdout is not a TTY.
# Fall back silently to default output if the flag is not supported
# (older wget versions, e.g. BusyBox wget).
if wget --show-progress -O "$2" "$1" 2>/dev/null; then
return 0
fi
wget -O "$2" "$1"
else
echo "Error: Neither 'curl' nor 'wget' is available. Please install one and retry." >&2
exit 1
fi
}
# --- Fetch latest release metadata from GitHub API ---
API_URL="https://api.github.com/repos/${REPO}/releases/latest"
echo "Fetching latest release information..."
RELEASE_JSON=$(_download_stdout "$API_URL")
# --- Parse asset download URL (no external tools required) ---
# The JSON key "browser_download_url" appears once per asset.
# We match the key followed by the URL, anchoring on the platform-arch suffix
# and the closing quote so that e.g. "linux-arm" does not match "linux-arm64".
# The ' *: *' pattern handles optional spaces around the colon (GitHub API adds spaces).
ASSET_URL=$(printf '%s' "$RELEASE_JSON" \
| grep -o '"browser_download_url" *: *"[^"]*-'"${SUFFIX}"'"' \
| grep -o 'https://[^"]*' \
| head -1)
if [ -z "$ASSET_URL" ]; then
echo "Error: Could not find a release asset for platform '${SUFFIX}'." >&2
exit 1
fi
# --- Extract tag name for display ---
TAG=$(printf '%s' "$RELEASE_JSON" \
| grep -o '"tag_name" *: *"[^"]*"' \
| head -1 \
| grep -o '"[^"]*"$' \
| tr -d '"')
echo "Downloading MinecraftClient ${TAG} (${SUFFIX})..."
_download_file "$ASSET_URL" "$OUTPUT"
chmod +x "$OUTPUT"
echo ""
echo "Downloaded: ./${OUTPUT}"
echo "Run with: ./${OUTPUT} --help"

View file

@ -748,6 +748,46 @@ redirectFrom:
- **Default:** `3.0`
#### `Auto_Tool_Switch`
- **Description:**
Automatically switch to a more suitable tool from your inventory before digging.
When `Durability_Limit` is above zero, tools below that durability threshold are skipped.
- **Available values:** `true` and `false`
- **Type:** `boolean`
- **Default:** `false`
#### `Durability_Limit`
- **Description:**
Will not use tools with less durability than this.
Set to `0` to disable this durability check.
- **Type:** `integer`
- **Default:** `2`
#### `Drop_Low_Durability_Tools`
- **Description:**
Drop the replaced tool if its remaining durability is below `Durability_Limit`.
This setting is only useful when `Auto_Tool_Switch` is enabled.
- **Available values:** `true` and `false`
- **Type:** `boolean`
- **Default:** `false`
#### `Dig_Timeout`
- **Description:**
@ -927,6 +967,7 @@ redirectFrom:
- **Description:**
Automatically catch fish using a fishing rod.
Bite detection combines bobber movement, bobber velocity, and splash sounds.
<div class="custom-container note"><p class="custom-container-title">Note</p>
@ -1103,6 +1144,66 @@ redirectFrom:
- **Default:** `0.2`
#### `Enable_Velocity_Detection`
- **Description:**
Enables bite detection using the fishing bobber velocity packet.
This improves reliability when bobber X/Z movement is constrained (for example by blocks near the water surface).
- **Available values:** `true` and `false`.
- **Type:** `boolean`
- **Default:** `true`
#### `Velocity_Hook_Threshold`
- **Description:**
Velocity Y threshold in blocks/tick for velocity-based bite detection.
Values below this threshold are considered a bite. Keep this value negative.
- **Type:** `float`
- **Default:** `-0.2`
#### `Enable_Sound_Detection`
- **Description:**
Enables bite detection using nearby splash sounds (`entity.fishing_bobber.splash`).
- **Available values:** `true` and `false`.
- **Type:** `boolean`
- **Default:** `true`
#### `Sound_Distance`
- **Description:**
Maximum distance in blocks between a splash sound and the tracked bobber to treat it as a bite.
- **Type:** `float`
- **Default:** `5.0`
#### `Detection_Warmup`
- **Description:**
Delay in seconds after bobber spawn before bite detection starts.
This helps ignore the initial cast-entry splash/motion.
- **Type:** `float`
- **Default:** `1.0`
#### `Log_Fish_Bobber`
- **Description:**

View file

@ -229,6 +229,120 @@ 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<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}");
}
}
```
## Scoreboard teams
Chat bots and C# scripts can read the current team state and react to team changes.
Useful methods and events:
- `GetTeams()` - returns a snapshot of all teams the server has sent
- `GetPlayerTeam(playerName)` - returns the team a specific player is on, or `null`
- `OnTeam(teamName, method, displayName, friendlyFlags, nameTagVisibility, collisionRule, color, prefix, suffix, players)` - called whenever a team packet arrives
The `method` byte tells you what changed:
- `0` - team created (includes full parameters and initial member list)
- `1` - team removed
- `2` - team parameters updated (display name, colors, rules)
- `3` - players added to the team
- `4` - players removed from the team
The `color` field is a `ChatFormatting` enum ordinal. Common values: `0`=black, `9`=blue, `10`=green, `12`=red, `14`=yellow, `-1`=none/reset.
The `nameTagVisibility` and `collisionRule` strings take values from the Minecraft wiki: `"always"`, `"never"`, `"hideForOtherTeams"`, `"hideForOwnTeam"` (visibility) or `"pushOtherTeams"`, `"pushOwnTeam"` (collision).
Example:
```csharp
//MCCScript 1.0
MCC.LoadBot(new TeamWatcher());
//MCCScript Extensions
public class TeamWatcher : ChatBot
{
public override void AfterGameJoined()
{
foreach (var team in GetTeams().Values)
LogToConsole($"Team '{team.Name}' has {team.Members.Count} member(s)");
}
public override void OnTeam(string teamName, byte method, string displayName,
byte friendlyFlags, string nameTagVisibility, string collisionRule,
int color, string prefix, string suffix, List<string> players)
{
switch (method)
{
case 0:
LogToConsole($"Team '{teamName}' created with {players.Count} member(s)");
break;
case 1:
LogToConsole($"Team '{teamName}' removed");
break;
case 3:
LogToConsole($"{string.Join(", ", players)} joined team '{teamName}'");
break;
case 4:
LogToConsole($"{string.Join(", ", players)} left team '{teamName}'");
break;
}
}
}
```
## 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).

View file

@ -4,6 +4,7 @@ title: Installation
# Installation
- [Quick Install (one-liner)](#quick-install)
- [YouTube Tutorials](#youtube-tutorials)
- [Download a compiled binary](#download-a-compiled-binary)
- [Building from the source code](#building-from-the-source-code)
@ -11,6 +12,42 @@ title: Installation
- [Run on Android](#run-on-android)
- [Run MCC 24/7 on a VPS](#run-on-a-vps)
## Quick Install
The quickest way to get MCC is to run the installer script for your platform. It auto-detects your OS and CPU architecture, fetches the latest release from GitHub, and saves the binary to your current directory.
### Linux / macOS
Open a terminal in the folder where you want MCC and run:
```bash
curl -fsSL https://mccteam.github.io/install.sh | sh
```
If you prefer `wget`:
```bash
wget -qO- https://mccteam.github.io/install.sh | sh
```
The script downloads `MinecraftClient` and marks it executable. Supported architectures: `x64`, `arm64`, `arm` (Linux only).
### Windows
Open **PowerShell** in the folder where you want MCC and run:
```powershell
iwr -useb https://mccteam.github.io/install.ps1 | iex
```
The script downloads `MinecraftClient.exe`. Supported architectures: `x64`, `x86`, `arm64`.
::: tip
You can also download the scripts directly and inspect them before running:
- Linux/macOS: [install.sh](https://mccteam.github.io/install.sh)
- Windows: [install.ps1](https://mccteam.github.io/install.ps1)
:::
## YouTube Tutorials
If you're not the kind of person that likes textual tutorials, our community has made video tutorials available on YouTube.

View file

@ -219,6 +219,54 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</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>
<summary><code>bed</code></summary>
@ -650,6 +698,79 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</details>
<details>
<summary><code>recipebook</code></summary>
- **Description:**
List unlocked recipe book entries and ask the server to place one of them into the active crafting inventory.
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Inventory Handling](configuration.md#inventoryhandling) enabled in order for this command to work.**
</div>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`craft` and `craftall` need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory.**
</div>
<div class="custom-container warning"><p class="custom-container-title">Warning</p>
**Recipe book crafting is supported on Minecraft `1.13+`.**
</div>
`list` shows the recipe book entries MCC is currently tracking.
On newer versions, the list can contain numeric display ids instead of plain recipe names. If you see something like `838: Oak Planks`, use `838` with `craft` or `craftall`.
`craft` and `craftall` send a recipe-book request to the server. They do not automatically take the result item for you. After the recipe appears in the active inventory, take the output slot the same way you would handle any other inventory action.
- **Usage:**
```
/recipebook list
```
```
/recipebook craft <recipe id>
```
```
/recipebook craftall <recipe id>
```
- **Examples:**
Show the currently tracked recipe book entries:
```
/recipebook list
```
Request one recipe placement:
```
/recipebook craft minecraft:oak_planks
```
On newer versions, use the numeric id shown by `/recipebook list`:
```
/recipebook craftall 838
```
If the recipe is placed in the player crafting grid, take the result from slot `0`:
```
/inventory player click 0
```
</details>
<details>
<summary><code>connect</code></summary>
@ -832,6 +953,30 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</details>
<details>
<summary><code>teams</code></summary>
- **Description:**
List all scoreboard teams the server has sent, along with their members and settings.
- **Usage:**
```
/teams
```
- **Example output:**
```
Team 'RedTeam' (display: RedTeam, color: 12, prefix: '', suffix: '', nameTagVisibility: always, collisionRule: always, friendlyFire: True, seeInvisibles: True)
Members (2): Steve, Alex
Team 'BlueTeam' (display: BlueTeam, color: 9, prefix: '', suffix: '', nameTagVisibility: always, collisionRule: always, friendlyFire: True, seeInvisibles: True)
No members.
```
</details>
<details>
<summary><code>useitem</code></summary>

View file

@ -135,6 +135,44 @@ Data source: `https://raw.githubusercontent.com/PrismarineJS/minecraft-data/mast
Uses `curl` with resume (`-C -`) for reliable download over slow connections. Falls back to manual download if retries are exhausted.
## gen_block_color_map.py -- Generate minimap block color JSON
Extracts block-to-MapColor RGB mappings from decompiled Minecraft source for the TUI minimap.
```bash
python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled
# -> MinecraftClient/Tui/MinimapBlockColors.json
```
Parses three files from the decompiled source:
- `MapColor.java` -- extracts the 64 base MapColor constants and their RGB values
- `DyeColor.java` -- maps dye colors to MapColor constants
- `Blocks.java` -- determines each block's assigned MapColor via `.mapColor()` calls
Output: `MinecraftClient/Tui/MinimapBlockColors.json` (embedded as a resource via `.csproj`). Contains color entries, plus lists of transparent, water, and ice materials.
Validates each block name against MCC's `Material.cs` enum. Blocks without a matching enum value are skipped.
## gen_entity_category_map.py -- Generate minimap entity category JSON
Extracts entity-to-MobCategory mappings from decompiled Minecraft source for the TUI minimap.
```bash
python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled
# -> MinecraftClient/Tui/MinimapEntityCategories.json
```
Parses `EntityType.java` to read each entity's `MobCategory` assignment from the `EntityType.Builder.of(Factory, MobCategory.XXX)` call. Maps Minecraft categories to MCC minimap categories:
- `MONSTER` -> hostile
- `CREATURE`/`AMBIENT`/`AXOLOTLS`/`WATER_*` -> passive
- `MISC` -> non_living
The script maintains manual override lists for:
- **Neutral mobs** (e.g. Enderman, Spider, Wolf, Bee) -- Minecraft has no "neutral" category; these are MONSTER or CREATURE in code but only attack when provoked
- **Passive overrides** (e.g. Villager, WanderingTrader) -- classified as MISC in Minecraft for spawning reasons but should appear as passive on the minimap
Output: `MinecraftClient/Tui/MinimapEntityCategories.json` (embedded as a resource via `.csproj`). Validates each entity name against MCC's `EntityType.cs` enum.
## Recommended workflow
1. Generate server reports (Step 0)
@ -145,6 +183,9 @@ Uses `curl` with resume (`-C -`) for reliable download over slow connections. Fa
- Entities: `gen_entity_palette.py`
- Metadata: `gen_entity_metadata_palette.py`
4. Update block collision shapes: `gen_block_shapes.py`
5. Add any missing enum values to `ItemType.cs`, `Material.cs`, `EntityType.cs`, `EntityMetaDataType.cs`
6. Update version routing (see SKILL.md)
7. Build and test
5. Update minimap data (if blocks or entities changed):
- Block colors: `gen_block_color_map.py`
- Entity categories: `gen_entity_category_map.py`
6. Add any missing enum values to `ItemType.cs`, `Material.cs`, `EntityType.cs`, `EntityMetaDataType.cs`
7. Update version routing (see SKILL.md)
8. Build and test

View file

@ -86,18 +86,90 @@ fi
mkdir -p "$MC_OFFICIAL/remapped_jar"
# --- Resolve version metadata from Mojang manifest ---
MANIFEST_URL="https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
VERSION_URL=$(curl -sL "$MANIFEST_URL" | python3 -c "
import json, sys
data = json.load(sys.stdin)
for v in data['versions']:
if v['id'] == '$VERSION':
print(v['url'])
break
")
if [[ -z "$VERSION_URL" ]]; then
echo "Error: version $VERSION not found in Mojang launcher manifest."
exit 1
fi
VERSION_META=$(curl -sL "$VERSION_URL")
MAPPING_KEY="${SIDE_LOWER}_mappings"
HAS_MAPPINGS=$(echo "$VERSION_META" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print('true' if '$MAPPING_KEY' in data.get('downloads', {}) else 'false')
")
echo "=== Decompiling Minecraft $VERSION ($SIDE) ==="
echo " Remapped JAR: $REMAPPED_JAR"
echo " Decompiled: $DECOMPILED_DIR"
echo " Obfuscated: $HAS_MAPPINGS"
echo ""
cd "$MC_OFFICIAL"
java -jar "$DECOMPILER_JAR" \
--version "$VERSION" \
--side "$SIDE" \
--decompile \
--output "$REMAPPED_JAR" \
--decompiled-output "$DECOMPILED_DIR"
if [[ "$HAS_MAPPINGS" == "true" ]]; then
# Obfuscated version: use --version/--side to auto-download jar + mappings + deobfuscate
java -jar "$DECOMPILER_JAR" \
--version "$VERSION" \
--side "$SIDE" \
--decompile \
--output "$REMAPPED_JAR" \
--decompiled-output "$DECOMPILED_DIR"
else
# Unobfuscated version (26.1+): download jar, extract inner jar from bundle, decompile directly.
# MinecraftDecompiler requires --mapping-path with --input, but unobfuscated versions
# have no mappings. We use Vineflower directly instead.
echo "No Proguard mappings for $VERSION; decompiling without deobfuscation."
JAR_URL=$(echo "$VERSION_META" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(data['downloads']['${SIDE_LOWER}']['url'])
")
ORIGINAL_JAR="$MC_OFFICIAL/remapped_jar/${VERSION}-${SIDE_LOWER}-original.jar"
if [[ ! -f "$ORIGINAL_JAR" ]]; then
echo "Downloading ${SIDE_LOWER}.jar ..."
curl -L -o "$ORIGINAL_JAR" "$JAR_URL"
fi
# Since 1.18, server.jar is a bundled jar containing the actual game jar inside
# META-INF/versions/<ver>/server-<ver>.jar. Extract it if present.
DECOMPILE_TARGET="$ORIGINAL_JAR"
EXTRACT_DIR=$(mktemp -d)
trap "rm -rf '$EXTRACT_DIR'" EXIT
if unzip -q -o "$ORIGINAL_JAR" "META-INF/versions.list" -d "$EXTRACT_DIR" 2>/dev/null; then
INNER_PATH=$(awk '{print $NF}' "$EXTRACT_DIR/META-INF/versions.list" | head -1)
if [[ -n "$INNER_PATH" ]]; then
unzip -q -o "$ORIGINAL_JAR" "META-INF/versions/$INNER_PATH" -d "$EXTRACT_DIR"
DECOMPILE_TARGET="$EXTRACT_DIR/META-INF/versions/$INNER_PATH"
echo "Extracted inner jar: $INNER_PATH"
fi
fi
# Use Vineflower directly (bundled with MinecraftDecompiler, or standalone)
VINEFLOWER_JAR="$MC_OFFICIAL/downloads/decompiler/vineflower.jar"
if [[ ! -f "$VINEFLOWER_JAR" ]]; then
# Fall back to vineflower bundled inside MinecraftDecompiler's cache
VINEFLOWER_JAR=$(find "$MC_OFFICIAL" -name "vineflower*.jar" -not -name "MinecraftDecompiler.jar" 2>/dev/null | head -1)
fi
if [[ -z "$VINEFLOWER_JAR" || ! -f "$VINEFLOWER_JAR" ]]; then
echo "Error: vineflower.jar not found. Place it at $MC_OFFICIAL/downloads/decompiler/vineflower.jar"
exit 1
fi
echo "Decompiling with Vineflower: $VINEFLOWER_JAR"
java -jar "$VINEFLOWER_JAR" "$DECOMPILE_TARGET" "$DECOMPILED_DIR"
fi
echo ""
echo "=== Done ==="
@ -108,29 +180,18 @@ if [[ "$SIDE" == "SERVER" ]]; then
DOWNLOADS_DIR="$MC_OFFICIAL/downloads/$VERSION"
if [[ ! -f "$DOWNLOADS_DIR/server.jar" ]]; then
mkdir -p "$DOWNLOADS_DIR"
# MinecraftDecompiler downloads the original jar into its cache;
# extract it from the bundled remapped jar or re-download via manifest.
echo ""
echo "Downloading server.jar for $VERSION into $DOWNLOADS_DIR ..."
MANIFEST_URL="https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"
VERSION_URL=$(curl -sL "$MANIFEST_URL" | python3 -c "
import json, sys
data = json.load(sys.stdin)
for v in data['versions']:
if v['id'] == '$VERSION':
print(v['url'])
break
")
if [[ -n "$VERSION_URL" ]]; then
SERVER_JAR_URL=$(curl -sL "$VERSION_URL" | python3 -c "
SERVER_JAR_URL=$(echo "$VERSION_META" | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(data['downloads']['server']['url'])
")
if [[ -n "$SERVER_JAR_URL" ]]; then
curl -L -o "$DOWNLOADS_DIR/server.jar" "$SERVER_JAR_URL"
echo "Downloaded server.jar"
else
echo "Warning: could not find version $VERSION in Mojang manifest; server.jar not downloaded."
echo "Warning: could not download server.jar for $VERSION."
fi
else
echo "server.jar already exists: $DOWNLOADS_DIR/server.jar"

View file

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
Generate MinimapBlockColors.json from decompiled Minecraft source.
Parses MapColor.java for the 62 base map colors (ID -> RGB), then parses
Blocks.java to extract each block's mapColor assignment, and outputs a
JSON mapping from MCC Material enum names (PascalCase) to RGB triples.
Usage:
python3 tools/gen_block_color_map.py <decompiled_root>
Example:
python3 tools/gen_block_color_map.py MinecraftOfficial/26.1-rc-2-decompiled
"""
import json
import re
import sys
from pathlib import Path
OUTPUT_PATH = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Tui" / "MinimapBlockColors.json")
MATERIAL_CS = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Mapping" / "Material.cs")
def mc_name_to_csharp(mc_name: str) -> str:
name = mc_name.removeprefix("minecraft:")
return "".join(word.capitalize() for word in name.split("_"))
def parse_map_colors(map_color_java: Path) -> dict[str, tuple[int, int, int]]:
"""Parse MapColor.java: extract name -> (R, G, B) for each constant."""
text = map_color_java.read_text()
colors: dict[str, tuple[int, int, int]] = {}
pattern = re.compile(
r'public static final MapColor\s+(\w+)\s*=\s*new\s+MapColor\(\s*(\d+)\s*,\s*(\d+)\s*\)')
for m in pattern.finditer(text):
name = m.group(1)
color_int = int(m.group(3))
r = (color_int >> 16) & 0xFF
g = (color_int >> 8) & 0xFF
b = color_int & 0xFF
colors[name] = (r, g, b)
return colors
def parse_dye_to_map_color(dye_color_java: Path) -> dict[str, str]:
"""Parse DyeColor.java: extract DyeColor name -> MapColor name."""
text = dye_color_java.read_text()
mapping: dict[str, str] = {}
pattern = re.compile(
r'(\w+)\(\d+,\s*"[^"]+",\s*\d+,\s*MapColor\.(\w+)')
for m in pattern.finditer(text):
mapping[m.group(1)] = m.group(2)
return mapping
def extract_block_declarations(text: str) -> list[tuple[str, str, str]]:
"""Extract (field_name, block_id, full_register_body) for each block declaration.
Returns list of (FIELD_NAME, "block_name", "register(...) content").
"""
results = []
# Find all "public static final Block FIELD = register(...)" declarations.
# These span multiple lines and end with ");".
# Strategy: find start pattern, then track parens to find matching end.
field_pattern = re.compile(
r'public\s+static\s+final\s+Block\s+(\w+)\s*=\s*register\s*\(')
pos = 0
while pos < len(text):
m = field_pattern.search(text, pos)
if not m:
break
field_name = m.group(1)
paren_start = m.end() - 1 # position of opening '('
# Find matching closing ')' then ';'
depth = 1
i = paren_start + 1
while i < len(text) and depth > 0:
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
i += 1
register_body = text[paren_start:i]
# Extract block name string from register call
name_match = re.search(r'(?:BlockIds\.(\w+)|"(\w+)")', register_body)
if name_match:
raw_id = name_match.group(1) or name_match.group(2)
block_id = raw_id.lower() if raw_id.isupper() else raw_id
else:
block_id = field_name.lower()
results.append((field_name, block_id, register_body))
pos = i
return results
def parse_blocks(blocks_java: Path, map_colors: dict[str, tuple[int, int, int]],
dye_to_map: dict[str, str]) -> dict[str, tuple[int, int, int]]:
"""Parse Blocks.java: extract block_name -> (R, G, B)."""
text = blocks_java.read_text()
declarations = extract_block_declarations(text)
print(f" Found {len(declarations)} block register() declarations")
# First pass: assign MapColor name to each block
field_to_block_id: dict[str, str] = {}
block_color_name: dict[str, str] = {}
map_color_direct = re.compile(r'\.mapColor\(MapColor\.(\w+)\)')
map_color_dye = re.compile(r'\.mapColor\(DyeColor\.(\w+)\)')
map_color_ref = re.compile(r'\.mapColor\((\w+)\.defaultMapColor\(\)')
map_color_waterlogged = re.compile(r'\.mapColor\(waterloggedMapColor\(MapColor\.(\w+)\)')
for field_name, block_id, body in declarations:
field_to_block_id[field_name] = block_id
mc = map_color_direct.search(body)
if mc:
block_color_name[block_id] = mc.group(1)
continue
mc = map_color_dye.search(body)
if mc:
dye_name = mc.group(1)
if dye_name in dye_to_map:
block_color_name[block_id] = dye_to_map[dye_name]
continue
mc = map_color_waterlogged.search(body)
if mc:
block_color_name[block_id] = mc.group(1)
continue
mc = map_color_ref.search(body)
if mc:
ref_field = mc.group(1)
ref_block = field_to_block_id.get(ref_field)
if ref_block and ref_block in block_color_name:
block_color_name[block_id] = block_color_name[ref_block]
# Second pass: resolve remaining BLOCK.defaultMapColor() references
for field_name, block_id, body in declarations:
if block_id in block_color_name:
continue
mc = map_color_ref.search(body)
if mc:
ref_field = mc.group(1)
ref_block = field_to_block_id.get(ref_field)
if ref_block and ref_block in block_color_name:
block_color_name[block_id] = block_color_name[ref_block]
result: dict[str, tuple[int, int, int]] = {}
for block_id, color_name in block_color_name.items():
if color_name in map_colors:
cs_name = mc_name_to_csharp(block_id)
result[cs_name] = map_colors[color_name]
return result
def load_known_materials() -> set[str]:
known = set()
if MATERIAL_CS.exists():
with open(MATERIAL_CS) as f:
for line in f:
m = re.match(r'\s+(\w+),?\s*$', line)
if m:
known.add(m.group(1))
return known
TRANSPARENT_BLOCKS = [
"Air", "CaveAir", "VoidAir",
"Glass", "GlassPane",
"WhiteStainedGlass", "OrangeStainedGlass", "MagentaStainedGlass",
"LightBlueStainedGlass", "YellowStainedGlass", "LimeStainedGlass",
"PinkStainedGlass", "GrayStainedGlass", "LightGrayStainedGlass",
"CyanStainedGlass", "PurpleStainedGlass", "BlueStainedGlass",
"BrownStainedGlass", "GreenStainedGlass", "RedStainedGlass",
"BlackStainedGlass",
"WhiteStainedGlassPane", "OrangeStainedGlassPane", "MagentaStainedGlassPane",
"LightBlueStainedGlassPane", "YellowStainedGlassPane", "LimeStainedGlassPane",
"PinkStainedGlassPane", "GrayStainedGlassPane", "LightGrayStainedGlassPane",
"CyanStainedGlassPane", "PurpleStainedGlassPane", "BlueStainedGlassPane",
"BrownStainedGlassPane", "GreenStainedGlassPane", "RedStainedGlassPane",
"BlackStainedGlassPane",
"TintedGlass", "Barrier", "Light", "StructureVoid",
]
WATER_BLOCKS = ["Water"]
ICE_BLOCKS = ["Ice", "PackedIce", "BlueIce", "FrostedIce"]
def main():
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
root = Path(sys.argv[1])
if not root.is_dir():
print(f"Error: {root} is not a directory")
sys.exit(1)
map_color_java = root / "net/minecraft/world/level/material/MapColor.java"
dye_color_java = root / "net/minecraft/world/item/DyeColor.java"
blocks_java = root / "net/minecraft/world/level/block/Blocks.java"
for f in [map_color_java, dye_color_java, blocks_java]:
if not f.exists():
print(f"Error: {f} not found")
sys.exit(1)
print("Parsing MapColor.java...")
map_colors = parse_map_colors(map_color_java)
print(f" Found {len(map_colors)} map colors")
print("Parsing DyeColor.java...")
dye_to_map = parse_dye_to_map_color(dye_color_java)
print(f" Found {len(dye_to_map)} dye->map color mappings")
print("Parsing Blocks.java...")
block_colors = parse_blocks(blocks_java, map_colors, dye_to_map)
print(f" Extracted colors for {len(block_colors)} blocks")
known_materials = load_known_materials()
if known_materials:
matched = {k: v for k, v in block_colors.items() if k in known_materials}
unmatched = [k for k in block_colors if k not in known_materials]
if unmatched:
print(f"\n {len(unmatched)} blocks not in Material.cs (will be skipped):")
for name in sorted(unmatched)[:20]:
print(f" {name}")
if len(unmatched) > 20:
print(f" ... and {len(unmatched) - 20} more")
block_colors = matched
print(f" {len(block_colors)} blocks matched to Material.cs entries")
output = {
"version": root.name.replace("-decompiled", "").replace("-client", ""),
"colors": {k: list(v) for k, v in sorted(block_colors.items())},
"transparent": sorted(TRANSPARENT_BLOCKS),
"water": WATER_BLOCKS,
"ice": ICE_BLOCKS,
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nGenerated {OUTPUT_PATH}")
print(f" {len(block_colors)} color entries")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,200 @@
#!/usr/bin/env python3
"""
Generate MinimapEntityCategories.json from decompiled Minecraft source.
Parses EntityType.java to extract each entity's MobCategory assignment,
then maps them to MCC minimap categories (hostile/passive/neutral/non_living).
Minecraft's MobCategory values:
MONSTER -> hostile (with neutral overrides for conditionally hostile mobs)
CREATURE -> passive (with neutral overrides for conditionally hostile mobs)
AMBIENT -> passive
AXOLOTLS -> passive
WATER_CREATURE -> passive
WATER_AMBIENT -> passive
UNDERGROUND_WATER_CREATURE -> passive
MISC -> non_living
Some mobs classified as MONSTER or CREATURE are actually "neutral" -- they
only attack when provoked. These are listed in NEUTRAL_OVERRIDES below and
should be updated when new conditionally-hostile mobs are added.
Usage:
python3 tools/gen_entity_category_map.py <decompiled_root>
Example:
python3 tools/gen_entity_category_map.py MinecraftOfficial/26.1-rc-2-decompiled
"""
import json
import re
import sys
from pathlib import Path
OUTPUT_PATH = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Tui" / "MinimapEntityCategories.json")
ENTITY_TYPE_CS = (Path(__file__).resolve().parent.parent
/ "MinecraftClient" / "Mapping" / "EntityType.cs")
def mc_name_to_csharp(mc_name: str) -> str:
name = mc_name.removeprefix("minecraft:")
return "".join(word.capitalize() for word in name.split("_"))
# Mobs that Minecraft classifies as MONSTER or CREATURE but behave as
# "neutral" -- they only attack when provoked. This list is maintained
# manually because there is no machine-readable flag in the game data.
NEUTRAL_OVERRIDES = {
"bee", "dolphin", "goat", "iron_golem", "llama", "panda",
"polar_bear", "snow_golem", "trader_llama", "wolf",
"zombified_piglin", "enderman", "spider", "cave_spider",
"copper_golem",
}
# Entities whose MobCategory in the game code doesn't match how they
# should appear on the minimap. For example, Villager and WanderingTrader
# are MISC in MC code (for spawning reasons) but should be passive on the map.
# ZombieHorse is MONSTER but is a rideable passive mob in practice.
PASSIVE_OVERRIDES = {
"villager", "wandering_trader", "zombie_horse",
}
# Player has its own category in MCC -- extracted from MISC to "player".
PLAYER_OVERRIDES = {"player"}
MC_TO_MCC = {
"MONSTER": "hostile",
"CREATURE": "passive",
"AMBIENT": "passive",
"AXOLOTLS": "passive",
"WATER_CREATURE": "passive",
"WATER_AMBIENT": "passive",
"UNDERGROUND_WATER_CREATURE": "passive",
"MISC": "non_living",
}
def extract_entity_categories(entity_type_java: Path) -> list[tuple[str, str, str]]:
"""Extract (entity_id, field_name, MobCategory) from EntityType.java.
Returns list of (entity_id, FIELD_NAME, MobCategory_name).
"""
text = entity_type_java.read_text()
results = []
field_pat = re.compile(
r'public\s+static\s+final\s+EntityType<[^>]+>\s+(\w+)\s*=\s*register\s*\(')
pos = 0
while pos < len(text):
m = field_pat.search(text, pos)
if not m:
break
field_name = m.group(1)
paren_start = m.end() - 1
depth = 1
i = paren_start + 1
while i < len(text) and depth > 0:
if text[i] == '(':
depth += 1
elif text[i] == ')':
depth -= 1
i += 1
body = text[paren_start:i]
name_match = re.search(r'"(\w+)"', body)
entity_id = name_match.group(1) if name_match else field_name.lower()
cat_match = re.search(r'MobCategory\.(\w+)', body)
mob_cat = cat_match.group(1) if cat_match else "MISC"
results.append((entity_id, field_name, mob_cat))
pos = i
return results
def load_known_entity_types() -> set[str]:
known = set()
if ENTITY_TYPE_CS.exists():
with open(ENTITY_TYPE_CS) as f:
for line in f:
m = re.match(r'\s+(\w+),?\s*$', line)
if m:
known.add(m.group(1))
return known
def main():
if len(sys.argv) != 2:
print(__doc__)
sys.exit(1)
root = Path(sys.argv[1])
entity_type_java = root / "net/minecraft/world/entity/EntityType.java"
if not entity_type_java.exists():
print(f"Error: {entity_type_java} not found")
sys.exit(1)
print("Parsing EntityType.java...")
entities = extract_entity_categories(entity_type_java)
print(f" Found {len(entities)} entity type declarations")
known_types = load_known_entity_types()
hostile = []
passive = []
neutral = []
non_living = []
for entity_id, field_name, mob_cat in entities:
cs_name = mc_name_to_csharp(entity_id)
if known_types and cs_name not in known_types:
continue
if entity_id in PLAYER_OVERRIDES:
continue
elif entity_id in NEUTRAL_OVERRIDES:
neutral.append(cs_name)
elif entity_id in PASSIVE_OVERRIDES:
passive.append(cs_name)
elif mob_cat in MC_TO_MCC:
cat = MC_TO_MCC[mob_cat]
if cat == "hostile":
hostile.append(cs_name)
elif cat == "passive":
passive.append(cs_name)
elif cat == "non_living":
non_living.append(cs_name)
else:
non_living.append(cs_name)
else:
non_living.append(cs_name)
output = {
"version": root.name.replace("-decompiled", "").replace("-client", ""),
"hostile": sorted(hostile),
"passive": sorted(passive),
"neutral": sorted(neutral),
"non_living": sorted(non_living),
}
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_PATH, 'w') as f:
json.dump(output, f, indent=2)
print(f"\nGenerated {OUTPUT_PATH}")
print(f" hostile: {len(hostile)}")
print(f" passive: {len(passive)}")
print(f" neutral: {len(neutral)}")
print(f" non_living: {len(non_living)}")
print(f" total: {len(hostile) + len(passive) + len(neutral) + len(non_living)}")
if __name__ == "__main__":
main()

View file

@ -32,6 +32,7 @@ EOF
VERSION="1.21.11-Vanilla"
MODE="classic"
PORT="25565"
PORT_SET_BY_USER=false
DO_BUILD=true
DEBUG_ON=false
FILE_INPUT=false
@ -40,7 +41,7 @@ while [[ $# -gt 0 ]]; do
case "$1" in
-v|--version) VERSION="$2"; shift 2 ;;
-m|--mode) MODE="$2"; shift 2 ;;
-p|--port) PORT="$2"; shift 2 ;;
-p|--port) PORT="$2"; PORT_SET_BY_USER=true; shift 2 ;;
--no-build) DO_BUILD=false; shift ;;
--debug-on) DEBUG_ON=true; shift ;;
--file-input) FILE_INPUT=true; shift ;;
@ -54,6 +55,10 @@ CFG="$TEST_ROOT/MinecraftClient.debug.ini"
MCC_LOG="$TEST_ROOT/mcc-debug.log"
INPUT_FILE="$REPO_ROOT/mcc_input.txt"
SESSION_NAME="mc-${VERSION//\./_}"
PREPARE_CFG_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh"
ENSURE_SERVER_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh"
PREFLIGHT_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/preflight_test_env.sh"
GET_PORT_SCRIPT="$REPO_ROOT/.skills/mcc-integration-testing/scripts/get_server_port.sh"
mkdir -p "$TEST_ROOT"
@ -64,6 +69,8 @@ echo " Config: $CFG"
echo " Log: $MCC_LOG"
echo ""
bash "$PREFLIGHT_SCRIPT" "$VERSION" >/dev/null
# --- Build ---
if $DO_BUILD; then
echo "[1/4] Building MCC..."
@ -75,21 +82,22 @@ fi
# --- Prepare config ---
echo "[2/4] Preparing config..."
cp "$REPO_ROOT/MinecraftClient.ini" "$CFG"
sed -i \
-e 's/Account = { Login = "[^"]*", Password = "[^"]*" }/Account = { Login = "CursorBot", Password = "-" }/' \
-e 's/TerrainAndMovements = false/TerrainAndMovements = true/' \
-e 's/InventoryHandling = false/InventoryHandling = true/' \
-e 's/EntityHandling = false/EntityHandling = true/' \
"$CFG"
bash "$PREPARE_CFG_SCRIPT" "$CFG" "${VERSION%-Vanilla}" CursorBot >/dev/null
if [[ "$MODE" == "tui" ]]; then
sed -i 's/ConsoleMode = "classic"/ConsoleMode = "tui"/' "$CFG"
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' 's/ConsoleMode = "classic"/ConsoleMode = "tui"/' "$CFG"
else
sed -i 's/ConsoleMode = "classic"/ConsoleMode = "tui"/' "$CFG"
fi
fi
if $DEBUG_ON; then
sed -i 's/DebugMessages = false/DebugMessages = true/' "$CFG"
if [[ "$(uname)" == "Darwin" ]]; then
sed -i '' 's/DebugMessages = false/DebugMessages = true/' "$CFG"
else
sed -i 's/DebugMessages = false/DebugMessages = true/' "$CFG"
fi
fi
echo " Config ready"
@ -99,14 +107,7 @@ echo "[3/4] Starting server $VERSION..."
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo " Server already running"
else
# Ensure offline mode
SERVER_DIR="$MCC_SERVERS/$VERSION"
if [[ -f "$SERVER_DIR/server.properties" ]]; then
sed -i 's/^online-mode=.*/online-mode=false/' "$SERVER_DIR/server.properties"
grep -q "^enable-rcon=" "$SERVER_DIR/server.properties" || echo "enable-rcon=true" >> "$SERVER_DIR/server.properties"
grep -q "^rcon.password=" "$SERVER_DIR/server.properties" || echo "rcon.password=test123" >> "$SERVER_DIR/server.properties"
grep -q "^rcon.port=" "$SERVER_DIR/server.properties" || echo "rcon.port=25575" >> "$SERVER_DIR/server.properties"
fi
bash "$ENSURE_SERVER_SCRIPT" "$VERSION" >/dev/null
mc-start "$VERSION" >/dev/null
echo -n " Waiting for server..."
@ -125,6 +126,10 @@ else
done
fi
if ! $PORT_SET_BY_USER; then
PORT="$(bash "$GET_PORT_SCRIPT" "$VERSION")"
fi
# --- Launch MCC ---
echo "[4/4] Launching MCC in $MODE mode..."
: > "$INPUT_FILE"

View file

@ -26,6 +26,9 @@ mc-cmd() { local v="${2:-1.20.6}"; echo "$1" > "$MCC_SERVERS/$v/stdin.pipe"; }
mc-log() { local s; s=$(_mc-session "${1:-1.20.6}"); tmux capture-pane -t "$s" -p -S "-${2:-50}"; }
mc-kill() { local v="${1:-1.20.6}" s; s=$(_mc-session "$v"); tmux kill-session -t "$s" 2>/dev/null; rm -f "$MCC_SERVERS/$v/stdin.pipe"; echo "Killed $s"; }
mc-list() { tmux list-sessions 2>/dev/null | grep "^mc-" || echo "No running MC servers"; }
mc-wait-ready() { bash "$MCC_REPO/.skills/mcc-integration-testing/scripts/preflight_test_env.sh" "${1:-1.20.6}" >/dev/null && source "$MCC_REPO/.skills/mcc-integration-testing/scripts/common.sh" && wait_for_server_ready "${1:-1.20.6}" "${2:-60}"; }
mc-wait-stop() { source "$MCC_REPO/.skills/mcc-integration-testing/scripts/common.sh" && wait_for_server_stop "${1:-1.20.6}" "${2:-60}"; }
mc-reset-test-env() { bash "$MCC_REPO/.skills/mcc-integration-testing/scripts/reset_shared_test_state.sh" "$@"; }
# --- RCON ---
mc-rcon() { bash "$MCC_REPO/tools/mc-rcon.sh" "$@"; }
@ -59,3 +62,4 @@ mcc-tui() {
mcc-debug() { bash "$MCC_REPO/tools/mcc-debug.sh" "$@"; }
mcc-log-mcc() { tail -f "${TMPDIR:-/tmp}/mcc-debug/mcc-debug.log" 2>/dev/null || echo "No MCC log found"; }
mcc-state() { echo "debug state" >> "$MCC_REPO/mcc_input.txt"; sleep 1; tail -30 "${TMPDIR:-/tmp}/mcc-debug/mcc-debug.log" 2>/dev/null; }
mcc-preflight() { bash "$MCC_REPO/.skills/mcc-integration-testing/scripts/preflight_test_env.sh" "$@"; }

View file

@ -5,6 +5,8 @@ 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"
# shellcheck source=.skills/mcc-integration-testing/scripts/common.sh
source "$REPO_ROOT/.skills/mcc-integration-testing/scripts/common.sh"
usage() {
cat <<'EOF'
@ -19,6 +21,7 @@ EOF
SERVER_DIR="${1:-}"
MC_VERSION="${2:-}"
PROFILE="${3:-}"
SERVER_PORT=""
if [[ -z "$SERVER_DIR" || -z "$MC_VERSION" || -z "$PROFILE" ]]; then
usage >&2
@ -37,6 +40,7 @@ MCC_LOG="$TEST_ROOT/mcc.log"
SERVER_LOG_FILE="$MCC_SERVERS/$SERVER_DIR/logs/latest.log"
INPUT_FILE="$REPO_ROOT/mcc_input.txt"
MCC_PID=""
SERVER_PORT="25565"
mkdir -p "$TEST_ROOT"
@ -59,33 +63,21 @@ wait_for_file_pattern() {
return 1
}
wait_for_server_ready() {
local timeout="${1:-60}"
wait_for_rcon_port_free() {
local timeout="${1:-30}"
local elapsed=0
while (( elapsed < timeout )); do
if mc-log "$SERVER_DIR" 250 2>/dev/null | grep -Fq "Done ("; then
if ! ss -ltn '( sport = :25575 )' 2>/dev/null | grep -Fq ':25575'; then
return 0
fi
sleep 1
((elapsed += 1))
done
echo "Timed out waiting for server readiness" >&2
echo "Timed out waiting for RCON port 25575 to become free" >&2
return 1
}
kill_other_servers() {
local sessions
sessions="$(tmux list-sessions 2>/dev/null | awk -F: '/^mc-/{print $1}' || true)"
if [[ -n "$sessions" ]]; then
while IFS= read -r session; do
[[ -z "$session" ]] && continue
tmux kill-session -t "$session" 2>/dev/null || true
done <<< "$sessions"
fi
}
cleanup() {
if [[ -n "${MCC_PID:-}" ]] && kill -0 "$MCC_PID" 2>/dev/null; then
echo "quit" >> "$INPUT_FILE" 2>/dev/null || true
@ -96,33 +88,28 @@ cleanup() {
if [[ -p "$MCC_SERVERS/$SERVER_DIR/stdin.pipe" ]]; then
echo "stop" > "$MCC_SERVERS/$SERVER_DIR/stdin.pipe" 2>/dev/null || true
sleep 2
wait_for_server_stop "$SERVER_DIR" 20 >/dev/null 2>&1 || true
fi
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
wait_for_rcon_port_free 30 || true
}
trap cleanup EXIT
prepare_config() {
cp "$REPO_ROOT/MinecraftClient.ini" "$CFG"
MCC_TEST_ACCOUNT_TYPE=mojang MCC_TEST_PASSWORD=- \
bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" \
"$REPO_ROOT/MinecraftClient.ini" "$CFG" "$MC_VERSION" CursorBot >/dev/null
sed -i \
-e 's/Account = { Login = "test", Password = "-" }/Account = { Login = "CursorBot", Password = "-" }/' \
-e "s/MinecraftVersion = \"auto\"/MinecraftVersion = \"$MC_VERSION\"/" \
sed_in_place \
-e "s#^Server = .*#Server = { Host = \"localhost\", Port = $SERVER_PORT }#" \
-e 's/TerrainAndMovements = false/TerrainAndMovements = true/' \
-e 's/InventoryHandling = false/InventoryHandling = true/' \
-e 's/EntityHandling = false/EntityHandling = true/' \
-e 's/AutoRespawn = false/AutoRespawn = true/' \
"$CFG"
sed -i '/^\[ChatBot.ScriptScheduler\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
sed -i '/^\[ChatBot.DiscordRpc\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
sed -i '/^\[ChatBot.AntiAFK\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
sed -i '/^\[ChatBot.AutoDig\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
sed -i '/^\[ChatBot.AutoAttack\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
sed -i '/^\[ChatBot.PlayerListLogger\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
sed -i '/^\[ChatBot.ReplayCapture\]/,/^\[/ { s/^Enabled = true/Enabled = false/; }' "$CFG"
disable_noisy_bots_in_ini "$CFG"
}
send_mcc_command() {
@ -195,23 +182,31 @@ modern_mob_and_effects() {
run_server_command "effect give CursorBot minecraft:regeneration 10 1 true"
}
prepare_config
kill_other_servers
bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/preflight_test_env.sh" "$SERVER_DIR" >/dev/null
bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/reset_shared_test_state.sh" --all >/dev/null
wait_for_rcon_port_free 30 || true
rm -f "$MCC_LOG" "$INPUT_FILE"
bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/ensure_offline_server.sh" "$SERVER_DIR" >/dev/null
SERVER_PORT="$(bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/get_server_port.sh" "$SERVER_DIR")"
if [[ -f "$MCC_SERVERS/$SERVER_DIR/server.properties" ]]; then
sed -i 's/^use-native-transport=.*/use-native-transport=false/' "$MCC_SERVERS/$SERVER_DIR/server.properties"
sed_in_place 's/^use-native-transport=.*/use-native-transport=false/' "$MCC_SERVERS/$SERVER_DIR/server.properties"
fi
mc-start "$SERVER_DIR" >/dev/null
wait_for_server_ready || exit 1
wait_for_server_ready "$SERVER_DIR" || exit 1
prepare_config
: > "$INPUT_FILE"
(
cd "$REPO_ROOT"
MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- "$CFG" > "$MCC_LOG" 2>&1
MCC_FILE_INPUT=1 dotnet run --project MinecraftClient -c Release --no-build -- \
"$CFG" \
CursorBot \
- \
"localhost:$SERVER_PORT" \
> "$MCC_LOG" 2>&1
) &
MCC_PID=$!

View file

@ -1,12 +1,38 @@
#!/bin/bash
# Start a Minecraft server in a tmux session with named pipe for stdin
# Servers live under $MCC_SERVERS or default to MinecraftOfficial/downloads/<version>/.
resolve_java_bin() {
if command -v java >/dev/null 2>&1 && java -version >/dev/null 2>&1; then
command -v java
return 0
fi
local candidate
for candidate in \
"${JAVA_BIN:-}" \
"/opt/homebrew/opt/openjdk/bin/java" \
"/usr/local/opt/openjdk/bin/java" \
"/usr/lib/jvm/default-java/bin/java"
do
[[ -z "$candidate" ]] && continue
if [[ -x "$candidate" ]]; then
if "$candidate" -version >/dev/null 2>&1; then
printf '%s\n' "$candidate"
return 0
fi
fi
done
return 1
}
VERSION="${1}"
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DOWNLOADS="${MCC_SERVERS:-$REPO_ROOT/MinecraftOfficial/downloads}"
DIR="$DOWNLOADS/$VERSION"
PIPE="$DIR/stdin.pipe"
SESSION="mc-${VERSION//\./_}"
JAVA_BIN="$(resolve_java_bin || true)"
if [ -z "$VERSION" ] || [ ! -d "$DIR" ]; then
echo "Error: Server directory not found${VERSION:+: $DIR}"
@ -20,6 +46,16 @@ if [ ! -f "$DIR/server.jar" ]; then
exit 1
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "Error: tmux is required to start local test servers"
exit 1
fi
if [[ -z "$JAVA_BIN" ]]; then
echo "Error: Java was not found on PATH. Install Java or set JAVA_BIN." >&2
exit 1
fi
if tmux has-session -t "$SESSION" 2>/dev/null; then
echo "Server $VERSION already running in tmux session '$SESSION'"
echo "View output: tmux capture-pane -t '$SESSION' -p -S -50"
@ -29,10 +65,14 @@ fi
rm -f "$DIR/world/session.lock"
if [[ -e "$PIPE" && ! -p "$PIPE" ]]; then
rm -f "$PIPE"
fi
[ -p "$PIPE" ] || mkfifo "$PIPE"
tmux new-session -d -s "$SESSION" -c "$DIR" \
"tail -f $PIPE | java -Xmx2G -Xms2G -jar server.jar nogui 2>&1"
"tail -f $PIPE | '$JAVA_BIN' -Xmx2G -Xms2G -jar server.jar nogui 2>&1"
echo "Server $VERSION started in tmux session '$SESSION'"
echo "Send commands: echo 'say hello' > $PIPE"