Merge remote-tracking branch 'origin/master' into feat/optimization

# Conflicts:
#	tools/run-creative-e2e.sh
This commit is contained in:
Anon 2026-04-03 15:44:59 +02:00
commit 22e987070a
106 changed files with 20031 additions and 3930 deletions

View file

@ -9,30 +9,38 @@ on:
env:
PROJECT: "MinecraftClient"
target-version: "net10.0"
compile-flags: "--self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded"
dotnet-version: "10.0.x"
compile-flags: "--self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true"
jobs:
determine-build:
runs-on: ubuntu-slim
if: >-
${{
!contains(github.event.head_commit.message, 'skipci') &&
!contains(github.event.pull_request.title, 'skipci')
}}
outputs:
skip: ${{ steps.check-skip.outputs.skip }}
steps:
- name: dummy action
run: "echo 'dummy action that checks if the build is to be skipped, if it is, this action does not run to break the entire build action'"
- name: Check skip CI
id: check-skip
run: |
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
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
fetch-translations:
strategy:
fail-fast: true
runs-on: ubuntu-latest
needs: determine-build
# Translations will only be fetched in the MCCTeam repository, since it needs crowdin secrets.
if: ${{ github.repository == 'MCCTeam/Minecraft-Console-Client' }}
if: ${{ needs.determine-build.outputs.skip != 'true' }}
timeout-minutes: 15
steps:
steps:
- name: Check cache
uses: actions/cache/restore@v3
id: cache-check
@ -46,12 +54,24 @@ jobs:
if: steps.cache-check.outputs.cache-hit != 'true'
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: 'true'
fetch-depth: 0
submodules: 'true'
- name: Check Crowdin secrets
id: crowdin-check
run: |
if [ -z "$CROWDIN_PROJECT_ID" ] || [ -z "$CROWDIN_PERSONAL_TOKEN" ]; then
echo "available=false" >> $GITHUB_OUTPUT
else
echo "available=true" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_TOKEN }}
- name: Download translations from crowdin
uses: crowdin/github-action@v1.6.0
if: steps.cache-check.outputs.cache-hit != 'true'
uses: crowdin/github-action@v2.4.0
if: steps.cache-check.outputs.cache-hit != 'true' && steps.crowdin-check.outputs.available == 'true'
with:
upload_sources: false
upload_translations: false
@ -77,8 +97,8 @@ jobs:
create-tag:
runs-on: ubuntu-slim
timeout-minutes: 5 # Wait 5 minutes in case of network issues/etc
needs: [determine-build]
if: ${{ needs.determine-build.result == 'success' }}
needs: determine-build
if: ${{ needs.determine-build.outputs.skip != 'true' }}
steps:
- id: make-tag
run: |
@ -111,9 +131,9 @@ jobs:
build:
runs-on: ubuntu-latest
# Check if we're not skipping build, tag is created, and translations successfully fetched (or skipped)
if: ${{ needs.determine-build.result == 'success' &&
needs.create-tag.result == 'success' &&
(needs.fetch-translations.result == 'success' || needs.fetch-translations.result == 'skipped')
if: ${{ needs.determine-build.outputs.skip != 'true' &&
needs.create-tag.result == 'success' &&
(needs.fetch-translations.result == 'success' || needs.fetch-translations.result == 'skipped')
}}
needs: [determine-build, fetch-translations, create-tag]
timeout-minutes: 15
@ -130,7 +150,6 @@ jobs:
- name: Get Current Date
run: |
echo date=$(date +'%Y%m%d') >> $GITHUB_ENV
echo date_dashed=$(date -u +'%Y-%m-%d') >> $GITHUB_ENV
- name: Restore Translations (if available)
@ -140,33 +159,34 @@ jobs:
key: "translation-${{ github.sha }}"
restore-keys: "translation-"
- name: Setup Environment Variables (early)
run: |
echo project-path=${{ github.workspace }}/${{ env.PROJECT }} >> $GITHUB_ENV
echo file-ext=${{ (startsWith(matrix.target, 'win') && '.exe') || '' }} >> $GITHUB_ENV
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
dotnet-version: ${{ env.dotnet-version }}
- name: Setup Environment Variables
run: |
echo target-out-path=${{ env.project-path }}/bin/Release/${{ env.target-version }}/${{ matrix.target }}/publish/ >> $GITHUB_ENV
echo assembly-info=${{ env.project-path }}/Properties/AssemblyInfo.cs >> $GITHUB_ENV
echo build-version-info=${{ needs.create-tag.outputs.build-tag }} >> $GITHUB_ENV
echo commit=$(echo ${{ github.sha }} | cut -c 1-7) >> $GITHUB_ENV
PROJECT_PATH=${{ github.workspace }}/${{ env.PROJECT }}
FILE_EXT=${{ (startsWith(matrix.target, 'win') && '.exe') || '' }}
TARGET_OUT_PATH=$PROJECT_PATH/bin/Release/${{ env.target-version }}/${{ matrix.target }}/publish/
echo "project-path=$PROJECT_PATH" >> $GITHUB_ENV
echo "file-ext=$FILE_EXT" >> $GITHUB_ENV
echo "target-out-path=$TARGET_OUT_PATH" >> $GITHUB_ENV
echo "assembly-info=$PROJECT_PATH/Properties/AssemblyInfo.cs" >> $GITHUB_ENV
echo "build-version-info=${{ needs.create-tag.outputs.build-tag }}" >> $GITHUB_ENV
echo "commit=$(echo ${{ github.sha }} | cut -c 1-7)" >> $GITHUB_ENV
- name: Setup Environment Variables (late)
- name: Setup Binaries Path
run: |
echo built-executable-path=${{ env.target-out-path }}${{ env.PROJECT }}${{ env.file-ext }} >> $GITHUB_ENV
- name: Set Version Info and Sentry Project (if applicable)
- name: Set Version Info
run: |
echo '' >> ${{ env.assembly-info }}
echo "[assembly: AssemblyConfiguration(\"GitHub build ${{ github.run_number }}, built on ${{ env.date_dashed }} from commit ${{ env.commit }}\")]" >> ${{ env.assembly-info }}
- name: Inject Sentry DSN
- name: Inject Sentry DSN (if applicable)
if: ${{ github.repository == 'MCCTeam/Minecraft-Console-Client' }}
run: |
grep -q 'SentryDSN = "";' ${{ env.project-path }}/Program.cs || { echo "SentryDSN pattern not found in Program.cs"; exit 1; }
@ -198,6 +218,17 @@ jobs:
with:
path: artifacts/
merge-multiple: true
- name: Truncate commit message for release name
id: release-name
run: |
SUBJECT=$(echo "$COMMIT_MSG" | head -n 1)
MAX=220
TRUNCATED="${SUBJECT:0:$MAX}"
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
@ -205,7 +236,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: "artifacts/**/*"
tag: ${{ needs.create-tag.outputs.build-tag }}
name: '${{ needs.create-tag.outputs.build-tag }}: ${{ github.event.head_commit.message }}'
name: ${{ steps.release-name.outputs.name }}
generateReleaseNotes: true
artifactErrorsFailBuild: true
allowUpdates: true

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`

@ -1 +1 @@
Subproject commit f63065282a4bd64758e7e8d30f232e3dc8ce2622
Subproject commit a9afc0df4ce79450b76acedffa1b549449cf69cb

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,67 @@
using System.Linq;
using System.Text;
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
namespace MinecraftClient.Commands
{
public class EffectsCommand : Command
{
public override string CmdName { get { return "effects"; } }
public override string CmdUsage { get { return "effects"; } }
public override string CmdDesc { get { return Translations.cmd_effects_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 => ShowEffects(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 int ShowEffects(CmdResult r)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetEntityHandlingEnabled())
return r.SetAndReturn(CmdResult.Status.FailNeedEntity);
var effects = handler.GetPlayerEffects()
.Values
.Where(effectData => !effectData.IsExpired)
.OrderBy(effectData => effectData.Effect)
.ToArray();
if (effects.Length == 0)
return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_effects_none);
StringBuilder response = new();
response.AppendLine(Translations.cmd_effects_header);
foreach (var effectData in effects)
{
response.AppendLine(string.Format(Translations.cmd_effects_entry,
effectData.GetDisplayName(), effectData.GetRemainingDurationText()));
}
return r.SetAndReturn(CmdResult.Status.Done, response.ToString().TrimEnd());
}
}
}

View file

@ -435,7 +435,7 @@ namespace MinecraftClient.Commands
return r.SetAndReturn(CmdResult.Status.Fail, msg);
}
if (container.Type != ContainerType.PlayerInventory)
if (!Tui.ContainerViewBase.HasTuiSupport(container.Type))
{
handler.Log.Warn(string.Format(Translations.cmd_inventory_tui_unsupported_container, inventoryId));
return r.SetAndReturn(CmdResult.Status.Fail);

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,8 @@
using Brigadier.NET;
using Brigadier.NET.Builder;
using MinecraftClient.CommandHandler;
using MinecraftClient.Inventory;
using MinecraftClient.Mapping;
using static MinecraftClient.CommandHandler.CmdResult;
namespace MinecraftClient.Commands
@ -8,7 +10,7 @@ namespace MinecraftClient.Commands
class UseItem : Command
{
public override string CmdName { get { return "useitem"; } }
public override string CmdUsage { get { return "useitem"; } }
public override string CmdUsage { get { return "useitem [x] [y] [z]"; } }
public override string CmdDesc { get { return Translations.cmd_useitem_desc; } }
public override void RegisterCommand(CommandDispatcher<CmdResult> dispatcher)
@ -21,6 +23,8 @@ namespace MinecraftClient.Commands
dispatcher.Register(l => l.Literal(CmdName)
.Executes(r => DoUseItem(r.Source))
.Then(l => l.Argument("Location", MccArguments.Location())
.Executes(r => DoUseItemAtLocation(r.Source, MccArguments.GetLocation(r, "Location"))))
.Then(l => l.Literal("_help")
.Executes(r => GetUsage(r.Source, string.Empty))
.Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))
@ -43,8 +47,34 @@ namespace MinecraftClient.Commands
if (!handler.GetInventoryEnabled())
return r.SetAndReturn(Status.FailNeedInventory);
if (handler.GetTerrainEnabled())
{
const double maxDistance = 4.5;
var raycast = RaycastHelper.RaycastBlock(handler, maxDistance, false);
if (raycast.Item1 && raycast.Item3.Type != Material.Air)
{
handler.PlaceBlock(raycast.Item2, Direction.Up, lookAtBlock: true);
handler.DoAnimation((int)Hand.MainHand);
return r.SetAndReturn(Status.Done, Translations.cmd_useitem_use);
}
}
handler.UseItemOnHand();
return r.SetAndReturn(Status.Done, Translations.cmd_useitem_use);
}
private int DoUseItemAtLocation(CmdResult r, Location block)
{
McClient handler = CmdResult.currentHandler!;
if (!handler.GetTerrainEnabled())
return r.SetAndReturn(Status.FailNeedTerrain);
Location current = handler.GetCurrentLocation();
block = block.ToAbsolute(current).ToFloor();
handler.PlaceBlock(block, Direction.Up, lookAtBlock: true);
handler.DoAnimation((int)Hand.MainHand);
return r.SetAndReturn(Status.Done, Translations.cmd_useitem_use);
}
}
}

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,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
namespace MinecraftClient.Inventory
{
@ -91,10 +91,11 @@ namespace MinecraftClient.Inventory
/// <param name="id">Container ID</param>
/// <param name="typeID">Container Type</param>
/// <param name="title">Container Title</param>
public Container(int id, int typeID, string title)
/// <param name="protocolVersion">Protocol version for version-specific mapping</param>
public Container(int id, int typeID, string title, int protocolVersion = 0)
{
ID = id;
Type = GetContainerType(typeID);
Type = GetContainerType(typeID, protocolVersion);
Title = title;
Items = new();
Properties = new();
@ -131,22 +132,62 @@ namespace MinecraftClient.Inventory
/// Get container type from Type ID
/// </summary>
/// <param name="typeID">Container Type ID</param>
/// <param name="protocolVersion">Protocol version (menu registry changed across versions)</param>
/// <returns>Container Type</returns>
public static ContainerType GetContainerType(int typeID)
public static ContainerType GetContainerType(int typeID, int protocolVersion = 0)
{
// https://wiki.vg/Inventory didn't state the inventory ID, assume that list start with 0
// MC 1.20.4 (protocol 765) added crafter_3x3 at index 7, shifting all subsequent IDs by +1.
// Registry order from decompiled MenuType.java:
// 1.14-1.20.2: generic_9x1..generic_3x3(6), anvil(7), beacon(8), ... stonecutter(22)
// 1.20.4+: generic_9x1..generic_3x3(6), crafter_3x3(7), anvil(8), beacon(9), ... stonecutter(24)
if (protocolVersion >= 765)
{
return typeID switch
{
#pragma warning disable format // @formatter:off
0 => ContainerType.Generic_9x1,
1 => ContainerType.Generic_9x2,
2 => ContainerType.Generic_9x3,
3 => ContainerType.Generic_9x4,
4 => ContainerType.Generic_9x5,
5 => ContainerType.Generic_9x6,
6 => ContainerType.Generic_3x3,
7 => ContainerType.Crafter,
8 => ContainerType.Anvil,
9 => ContainerType.Beacon,
10 => ContainerType.BlastFurnace,
11 => ContainerType.BrewingStand,
12 => ContainerType.Crafting,
13 => ContainerType.Enchantment,
14 => ContainerType.Furnace,
15 => ContainerType.Grindstone,
16 => ContainerType.Hopper,
17 => ContainerType.Lectern,
18 => ContainerType.Loom,
19 => ContainerType.Merchant,
20 => ContainerType.ShulkerBox,
21 => ContainerType.SmightingTable,
22 => ContainerType.Smoker,
23 => ContainerType.Cartography,
24 => ContainerType.Stonecutter,
_ => ContainerType.Unknown,
#pragma warning restore format // @formatter:on
};
}
return typeID switch
{
0 => ContainerType.Generic_9x1,
1 => ContainerType.Generic_9x2,
2 => ContainerType.Generic_9x3,
3 => ContainerType.Generic_9x4,
4 => ContainerType.Generic_9x5,
5 => ContainerType.Generic_9x6,
6 => ContainerType.Generic_3x3,
7 => ContainerType.Anvil,
8 => ContainerType.Beacon,
9 => ContainerType.BlastFurnace,
#pragma warning disable format // @formatter:off
0 => ContainerType.Generic_9x1,
1 => ContainerType.Generic_9x2,
2 => ContainerType.Generic_9x3,
3 => ContainerType.Generic_9x4,
4 => ContainerType.Generic_9x5,
5 => ContainerType.Generic_9x6,
6 => ContainerType.Generic_3x3,
7 => ContainerType.Anvil,
8 => ContainerType.Beacon,
9 => ContainerType.BlastFurnace,
10 => ContainerType.BrewingStand,
11 => ContainerType.Crafting,
12 => ContainerType.Enchantment,
@ -160,7 +201,8 @@ namespace MinecraftClient.Inventory
20 => ContainerType.Smoker,
21 => ContainerType.Cartography,
22 => ContainerType.Stonecutter,
_ => ContainerType.Unknown,
_ => ContainerType.Unknown,
#pragma warning restore format // @formatter:on
};
}

View file

@ -1,4 +1,4 @@
namespace MinecraftClient.Inventory
namespace MinecraftClient.Inventory
{
// For MC 1.14 after ONLY
public enum ContainerType
@ -10,6 +10,7 @@
Generic_9x5,
Generic_9x6,
Generic_3x3,
Crafter,
Anvil,
Beacon,
BlastFurnace,

View file

@ -1,4 +1,4 @@
namespace MinecraftClient.Inventory
namespace MinecraftClient.Inventory
{
public static class ContainerTypeExtensions
{
@ -13,9 +13,14 @@
{
#pragma warning disable format // @formatter:off
ContainerType.PlayerInventory => 46,
ContainerType.Generic_9x1 => 45,
ContainerType.Generic_9x2 => 54,
ContainerType.Generic_9x3 => 63,
ContainerType.Generic_9x4 => 72,
ContainerType.Generic_9x5 => 81,
ContainerType.Generic_9x6 => 90,
ContainerType.Generic_3x3 => 45,
ContainerType.Crafter => 45,
ContainerType.Crafting => 46,
ContainerType.BlastFurnace => 39,
ContainerType.Furnace => 39,
@ -27,6 +32,7 @@
ContainerType.Anvil => 39,
ContainerType.Hopper => 41,
ContainerType.ShulkerBox => 63,
ContainerType.SmightingTable => 39,
ContainerType.Loom => 40,
ContainerType.Stonecutter => 38,
ContainerType.Lectern => 37,
@ -52,6 +58,7 @@
ContainerType.Generic_9x3 => AsciiArt.Container_Generic_9x3,
ContainerType.Generic_9x6 => AsciiArt.Container_Generic_9x6,
ContainerType.Generic_3x3 => AsciiArt.Container_Generic_3x3,
ContainerType.Crafter => AsciiArt.Container_Generic_3x3,
ContainerType.Crafting => AsciiArt.Container_Crafting,
ContainerType.BlastFurnace => AsciiArt.Container_Furnace,
ContainerType.Furnace => AsciiArt.Container_Furnace,

View file

@ -0,0 +1,194 @@
namespace MinecraftClient.Inventory;
using System;
using System.Collections.Generic;
using System.Linq;
using MinecraftClient.Protocol;
using MinecraftClient.Protocol.Message;
/// <summary>
/// Represents an active status effect on an entity
/// </summary>
public class EffectData
{
/// <summary>
/// The type of effect
/// </summary>
public Effects Effect { get; set; }
/// <summary>
/// Effect amplifier (level - 1, e.g., 0 = level I, 1 = level II)
/// </summary>
public int Amplifier { get; set; }
/// <summary>
/// Duration in ticks (20 ticks = 1 second). -1 for infinite.
/// </summary>
public int Duration { get; set; }
/// <summary>
/// Effect flags (ambient, show particles, show icon)
/// </summary>
public byte Flags { get; set; }
/// <summary>
/// Time when the effect was applied
/// </summary>
public DateTime StartTime { get; set; }
public EffectData(Effects effect, int amplifier, int duration, byte flags)
{
Effect = effect;
Amplifier = amplifier;
Duration = duration;
Flags = flags;
StartTime = DateTime.UtcNow;
}
/// <summary>
/// Check if this is an infinite duration effect
/// </summary>
public bool IsInfinite => Duration == -1 || Duration == int.MaxValue;
/// <summary>
/// Check if the effect has expired
/// </summary>
public bool IsExpired
{
get
{
if (IsInfinite) return false;
return GetElapsedTicks() >= Duration;
}
}
/// <summary>
/// Get remaining duration in ticks
/// </summary>
public int RemainingTicks
{
get
{
if (IsInfinite) return -1;
return Math.Max(0, Duration - GetElapsedTicks());
}
}
/// <summary>
/// Get remaining duration in seconds
/// </summary>
public int RemainingSeconds
{
get
{
if (IsInfinite) return -1;
return (RemainingTicks + 19) / 20;
}
}
/// <summary>
/// Get the translated effect name from Minecraft translations
/// </summary>
public string GetTranslatedName()
{
var key = $"effect.minecraft.{Effect.ToString().ToUnderscoreCase()}";
var translated = ChatParser.TranslateString(key);
return string.IsNullOrEmpty(translated) ? Effect.ToString() : translated;
}
/// <summary>
/// Get the translated effect name with level when applicable
/// </summary>
public string GetDisplayName()
{
string translatedName = GetTranslatedName();
if (Amplifier <= 0)
return translatedName;
return string.Format(Translations.effect_name_with_amplifier, translatedName,
EnchantmentMapping.ConvertLevelToRomanNumbers(Amplifier + 1));
}
/// <summary>
/// Get the translated effect name prefixed with the best-fit indefinite article
/// </summary>
public string GetDisplayNameWithArticle()
{
string displayName = GetDisplayName();
char? firstLetter = displayName
.TrimStart()
.FirstOrDefault(char.IsLetter);
if (firstLetter is null)
return displayName;
string article = "AEIOUaeiou".Contains(firstLetter.Value)
? Translations.effect_article_an
: Translations.effect_article_a;
return $"{article} {displayName}";
}
/// <summary>
/// Get the configured short duration label for the remaining time
/// </summary>
public string GetRemainingDurationText()
{
return FormatShortDuration(RemainingSeconds);
}
/// <summary>
/// Get the configured short duration label for the initial effect duration
/// </summary>
public string GetInitialDurationText()
{
if (IsInfinite)
return Translations.effect_duration_unlimited;
int durationSeconds = (Duration + 19) / 20;
return FormatShortDuration(durationSeconds);
}
/// <summary>
/// Format a duration for compact UI output
/// </summary>
/// <param name="seconds">Duration in seconds, -1 for unlimited</param>
public static string FormatShortDuration(int seconds)
{
if (seconds < 0)
return Translations.effect_duration_short_unlimited;
if (seconds < 60)
return string.Format(Translations.effect_duration_short_seconds, seconds);
int minutes = seconds / 60;
int remainingSeconds = seconds % 60;
if (seconds < 3600)
{
return remainingSeconds == 0
? string.Format(Translations.effect_duration_short_minutes, minutes)
: string.Format(Translations.effect_duration_short_minutes_seconds, minutes, remainingSeconds);
}
int hours = seconds / 3600;
int remainingMinutes = (seconds % 3600) / 60;
return remainingMinutes == 0
? string.Format(Translations.effect_duration_short_hours, hours)
: string.Format(Translations.effect_duration_short_hours_minutes, hours, remainingMinutes);
}
private int GetElapsedTicks()
{
return (int)((DateTime.UtcNow - StartTime).TotalMilliseconds / 50);
}
}
/// <summary>
/// Extension method for converting PascalCase to snake_case
/// </summary>
public static class StringExtensions
{
public static string ToUnderscoreCase(this string str)
{
return string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLower();
}
}

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

@ -99,6 +99,11 @@ namespace MinecraftClient.Mapping
/// </summary>
public Dictionary<int, Item> Equipment;
/// <summary>
/// Active status effects on this entity
/// </summary>
public Dictionary<Effects, EffectData> ActiveEffects { get; private set; }
/// <summary>
/// Create a new entity based on Entity ID, Entity Type and location
/// </summary>
@ -112,6 +117,7 @@ namespace MinecraftClient.Mapping
Location = location;
Health = 1.0f;
Equipment = new Dictionary<int, Item>();
ActiveEffects = new Dictionary<Effects, EffectData>();
Item = new Item(ItemType.Air, 0, null);
}
@ -128,6 +134,7 @@ namespace MinecraftClient.Mapping
Location = location;
Health = 1.0f;
Equipment = new Dictionary<int, Item>();
ActiveEffects = new Dictionary<Effects, EffectData>();
Item = new Item(ItemType.Air, 0, null);
Yaw = yaw * (1F / 256) * 360; // to angle in 360 degree
Pitch = pitch * (1F / 256) * 360;
@ -151,6 +158,7 @@ namespace MinecraftClient.Mapping
Name = name;
Health = 1.0f;
Equipment = new Dictionary<int, Item>();
ActiveEffects = new Dictionary<Effects, EffectData>();
Item = new Item(ItemType.Air, 0, null);
Yaw = yaw * (1F / 256) * 360; // to angle in 360 degree
Pitch = pitch * (1F / 256) * 360;

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

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

@ -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();
@ -102,6 +107,15 @@ namespace MinecraftClient
private int playerLevel;
private int playerTotalExperience;
private byte CurrentSlot = 0;
// 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;
@ -141,6 +155,40 @@ namespace MinecraftClient
public bool GetIsSupportPreviewsChat() { return isSupportPreviewsChat; }
public float GetHealth() { return playerHealth; }
public int GetSaturation() { return playerFoodSaturation; }
/// <summary>
/// Get the player's active effects
/// </summary>
/// <returns>Dictionary of active effects</returns>
public Dictionary<Effects, EffectData> GetPlayerEffects()
{
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; }
@ -616,6 +664,26 @@ namespace MinecraftClient
SendRespawnPacket();
}
// Check for expired effects
if (playerEffects.Count > 0)
{
var expiredEffects = playerEffects
.Where(e => e.Value.IsExpired)
.Select(e => e.Key)
.ToList();
foreach (var effect in expiredEffects)
{
if (!playerEffects.Remove(effect, out var effectData))
continue;
ConsoleIO.WriteLine(string.Format(Translations.bot_effect_expired, effectData.GetDisplayName()));
if (entities.TryGetValue(playerEntityID, out var playerEntity))
playerEntity.ActiveEffects.Remove(effect);
}
}
lock (threadTasksLock)
{
while (threadTasks.Count > 0)
@ -1204,6 +1272,7 @@ namespace MinecraftClient
inventoryHandlingEnabled = false;
inventoryHandlingRequested = false;
inventories.Clear();
ClearUnlockedRecipes();
}
return true;
}
@ -1305,6 +1374,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>
@ -1351,6 +1468,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>
@ -1446,6 +1579,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)
@ -2443,6 +2582,7 @@ namespace MinecraftClient
inventories.Clear();
inventories[0] = new Container(0, ContainerType.PlayerInventory, "Player Inventory");
ClearUnlockedRecipes();
return true;
}
@ -2526,6 +2666,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++)
@ -2543,6 +2690,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>
@ -2644,6 +2837,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
@ -3091,6 +3309,13 @@ namespace MinecraftClient
Log.Info(string.Format(Translations.extra_inventory_open, inventoryID, inventory.Title));
Log.Info(Translations.extra_inventory_interact);
DispatchBotEvent(bot => bot.OnInventoryOpen(inventoryID));
if (ConsoleIO.Backend is Tui.TuiConsoleBackend
&& Tui.ContainerViewBase.HasTuiSupport(inventory.Type)
&& Tui.InventoryTuiHost.CanLaunch)
{
Tui.InventoryTuiHost.Launch(this, inventoryID);
}
}
}
@ -3113,6 +3338,8 @@ namespace MinecraftClient
Log.Info(string.Format(Translations.extra_inventory_close, inventoryID));
DispatchBotEvent(bot => bot.OnInventoryClose(inventoryID));
}
Tui.InventoryTuiHost.NotifyInventoryClosed(inventoryID);
}
/// <summary>
@ -3394,8 +3621,61 @@ namespace MinecraftClient
/// </summary>
public void OnEntityEffect(int entityid, Effects effect, int amplifier, int duration, byte flags, bool hasFactorData, Dictionary<string, object>? factorCodec)
{
if (entities.ContainsKey(entityid))
DispatchBotEvent(bot => bot.OnEntityEffect(entities[entityid], effect, amplifier, duration, flags));
Entity? entity = null;
if (entities.TryGetValue(entityid, out var trackedEntity))
{
entity = trackedEntity;
}
var effectData = new EffectData(effect, amplifier, duration, flags);
entity?.ActiveEffects[effect] = effectData;
if (entityid == playerEntityID)
{
playerEffects.TryGetValue(effect, out var previousPlayerEffect);
playerEffects[effect] = effectData;
bool shouldAnnounceEffectGain = previousPlayerEffect is null
|| previousPlayerEffect.Amplifier != amplifier
|| (effectData.IsInfinite && !previousPlayerEffect.IsInfinite)
|| (!effectData.IsInfinite && duration > previousPlayerEffect.RemainingTicks + 20);
if (shouldAnnounceEffectGain)
{
ConsoleIO.WriteLine(string.Format(Translations.bot_effect_gained,
effectData.GetDisplayNameWithArticle(), effectData.GetInitialDurationText()));
}
}
if (entity is not null)
DispatchBotEvent(bot => bot.OnEntityEffect(entity, effect, amplifier, duration, flags));
}
/// <summary>
/// Called when an entity has an effect removed
/// </summary>
/// <param name="entityid">Entity ID</param>
/// <param name="effect">Effect that was removed</param>
public void OnRemoveEntityEffect(int entityid, Effects effect)
{
Entity? entity = null;
EffectData? removedEffectData = null;
if (entities.TryGetValue(entityid, out var trackedEntity))
{
entity = trackedEntity;
if (entity.ActiveEffects.Remove(effect, out var entityEffectData))
removedEffectData = entityEffectData;
}
if (entityid == playerEntityID && playerEffects.Remove(effect, out var playerEffectData))
removedEffectData ??= playerEffectData;
if (entityid == playerEntityID && removedEffectData is not null)
ConsoleIO.WriteLine(string.Format(Translations.bot_effect_expired, removedEffectData.GetDisplayName()));
if (entity is not null)
DispatchBotEvent(bot => bot.OnRemoveEntityEffect(entity, effect));
}
/// <summary>
@ -3551,6 +3831,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>
@ -3560,6 +3878,9 @@ namespace MinecraftClient
{
if (EntityID == playerEntityID)
{
foreach (var kvp in prop)
playerAttributes[kvp.Key] = kvp.Value;
DispatchBotEvent(bot => bot.OnPlayerProperty(prop));
}
}
@ -3765,7 +4086,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>
@ -3959,6 +4351,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
@ -3972,6 +4453,51 @@ namespace MinecraftClient
return handler.ClickContainerButton(windowId, buttonId);
}
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

@ -20,6 +20,8 @@
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Physics\BlockShapeData.json" LogicalName="BlockShapeData.json" />
<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

@ -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;
@ -58,6 +60,17 @@ namespace MinecraftClient
// Setting this string to an empty string will disable Sentry
private const string SentryDSN = "";
/// <summary>
/// Snapshot of all state collected before the console backend is initialized.
/// Passed to <see cref="ProcessStartupState"/> once the backend is ready.
/// </summary>
internal sealed class StartupState
{
public Settings.ConfigLoadResult ConfigResult { get; init; }
public bool NewlyGenerated { get; init; }
public bool SentryEnabled { get; init; }
}
/// <summary>
/// The main entry point of Minecraft Console Client
/// </summary>
@ -103,7 +116,6 @@ namespace MinecraftClient
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
});
//Setup ConsoleIO
ConsoleIO.LogPrefix = "§8[MCC] ";
if (args.Length >= 1 && args[^1] == "BasicIO" || args.Length >= 1 && args[^1] == "BasicIO-NoColor")
{
@ -115,133 +127,259 @@ namespace MinecraftClient
args = args.Where(o => !Object.ReferenceEquals(o, args[^1])).ToArray();
}
//Debug input ?
if (args.Length == 1 && args[0] == "--keyboard-debug")
{
if (!ConsoleIO.BasicIO)
{
ConsoleIO.Backend = new ClassicConsoleBackend();
ConsoleIO.Backend.Init();
}
ConsoleIO.WriteLine("Keyboard debug mode: Press any key to display info");
ConsoleIO.DebugReadInput();
}
// --- Load config as early as possible (no printing yet) ---
Settings.ConfigLoadResult configResult;
bool newlyGenerated = false;
if (args.Length >= 1 && File.Exists(args[0]) && Settings.ToLowerIfNeed(Path.GetExtension(args[0])) == ".ini")
{
configResult = Settings.LoadFromFile(args[0]);
settingsIniPath = args[0];
List<string> args_tmp = args.ToList<string>();
args_tmp.RemoveAt(0);
args = args_tmp.ToArray();
}
else if (File.Exists("MinecraftClient.ini"))
{
configResult = Settings.LoadFromFile("MinecraftClient.ini");
}
else
{
configResult = new Settings.ConfigLoadResult { Success = true, NeedWriteDefault = true };
newlyGenerated = true;
}
if (configResult.NeedWriteDefault)
Config.Main.Advanced.Language = Settings.GetDefaultGameLanguage();
if (!Config.Main.Advanced.EnableSentry)
_sentrySdk?.Dispose();
var startupState = new StartupState
{
ConfigResult = configResult,
NewlyGenerated = newlyGenerated,
SentryEnabled = SentryDSN != string.Empty,
};
// --- Determine console mode and initialize backend ---
if (!OperatingSystem.IsWindows())
InstallCursesNativeResolver();
if (!ConsoleIO.BasicIO && Config.Console.General.ConsoleMode == ConsoleModeType.tui)
{
ConsoleIO.Backend?.Shutdown();
try
{
var tuiBackend = new Tui.TuiConsoleBackend();
ConsoleIO.Backend = tuiBackend;
tuiBackend.RunTuiMainLoop(args, startupState);
}
catch (Exception ex)
{
HandleTuiStartupFailure(ex);
}
return;
}
// Classic mode: init backend, then print and process startup state.
if (!ConsoleIO.BasicIO)
{
ConsoleIO.Backend = new ClassicConsoleBackend();
ConsoleIO.Backend.Init();
}
ConsoleIO.WriteLine($"Minecraft Console Client v{Version} - for MC {MCLowestVersion} to {MCHighestVersion} - Github.com/MCCTeam");
//Build information to facilitate processing of bug reports
if (BuildInfo is not null)
ConsoleIO.WriteLineFormatted("§8" + BuildInfo);
//Debug input ?
if (args.Length == 1 && args[0] == "--keyboard-debug")
{
ConsoleIO.WriteLine("Keyboard debug mode: Press any key to display info");
ConsoleIO.DebugReadInput();
}
//Process ini configuration file
{
bool loadSucceed, needWriteDefaultSetting, newlyGenerated = false;
if (args.Length >= 1 && File.Exists(args[0]) && Settings.ToLowerIfNeed(Path.GetExtension(args[0])) == ".ini")
{
(loadSucceed, needWriteDefaultSetting) = Settings.LoadFromFile(args[0]);
settingsIniPath = args[0];
//remove ini configuration file from arguments array
List<string> args_tmp = args.ToList<string>();
args_tmp.RemoveAt(0);
args = args_tmp.ToArray();
}
else if (File.Exists("MinecraftClient.ini"))
{
(loadSucceed, needWriteDefaultSetting) = Settings.LoadFromFile("MinecraftClient.ini");
}
else
{
loadSucceed = true;
needWriteDefaultSetting = true;
newlyGenerated = true;
}
if (needWriteDefaultSetting)
{
Config.Main.Advanced.Language = Settings.GetDefaultGameLanguage();
WriteBackSettings(false);
if (newlyGenerated)
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_settings_generated);
ConsoleIO.WriteLine(Translations.mcc_run_with_default_settings);
// Only show the Sentry message if the DSN is not empty
// as Sentry will not be initialized if the DSN is empty
if (SentryDSN != string.Empty)
{
ConsoleIO.WriteLine(Translations.mcc_sentry_logging);
}
}
else if (!loadSucceed)
{
ConsoleIO.Backend?.StopReadThread();
string command = " ";
while (command.Length > 0)
{
ConsoleIO.WriteLine(string.Empty);
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_invaild_config, Config.Main.Advanced.InternalCmdChar.ToLogString()));
if (ConsoleIO.Backend is Tui.TuiConsoleBackend)
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
else
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
command = ConsoleIO.ReadLine().Trim();
if (command.Length > 0)
{
if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' '
&& command[0] == Config.Main.Advanced.InternalCmdChar.ToChar())
command = command[1..];
if (command.StartsWith("exit") || command.StartsWith("quit"))
{
return;
}
else if (command.StartsWith("new"))
{
Config.Main.Advanced.Language = Settings.GetDefaultGameLanguage();
WriteBackSettings(true);
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_gen_new_config, settingsIniPath));
return;
}
}
else
{
return;
}
}
return;
}
else
{
//Load external translation file. Should be called AFTER settings loaded
if (!Config.Main.Advanced.Language.StartsWith("en"))
ConsoleIO.WriteLine(string.Format(Translations.mcc_help_us_translate, Settings.TranslationProjectUrl));
WriteBackSettings(true); // format
}
if (!Config.Main.Advanced.EnableSentry)
_sentrySdk?.Dispose();
}
// Switch to TUI mode if configured (must happen after config load)
if (!ConsoleIO.BasicIO && Config.Console.General.ConsoleMode == ConsoleModeType.tui)
{
ConsoleIO.Backend?.Shutdown();
var tuiBackend = new Tui.TuiConsoleBackend();
ConsoleIO.Backend = tuiBackend;
tuiBackend.RunTuiMainLoop(args);
if (!ProcessStartupState(startupState))
return;
}
ContinueAfterTuiInit(args);
// Wait for this issue to be fixed before enabling it: https://github.com/Consolonia/Consolonia/issues/602
// MaybePrintClassicModeTuiRecommendation();
RunStartupSequence(args);
}
/// <summary>
/// Continues MCC startup after console mode has been determined.
/// Called directly from Main for classic/basic mode, or from a background
/// thread for TUI mode (after the Avalonia UI loop has started).
/// 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>
internal static void ContinueAfterTuiInit(string[] args)
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
/// the view is initialized.
/// </summary>
/// <returns>True if startup can continue; false if config load failed and user chose to exit.</returns>
internal static bool ProcessStartupState(StartupState state)
{
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);
ConsoleIO.WriteLineFormatted("§c" + string.Format(Translations.mcc_backup_old_config, cfg.LegacyBackupPath));
}
if (state.NewlyGenerated)
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_settings_generated);
ConsoleIO.WriteLine(Translations.mcc_run_with_default_settings);
if (state.SentryEnabled)
ConsoleIO.WriteLine(Translations.mcc_sentry_logging);
}
else if (!cfg.Success)
{
ConsoleIO.WriteLineFormatted("§c" + Translations.config_load_fail);
if (cfg.ErrorMessage is not null)
ConsoleIO.WriteLine(cfg.ErrorMessage);
HandleConfigLoadFailure();
return false;
}
else
{
WriteBackSettings(true);
if (!Config.Main.Advanced.Language.StartsWith("en"))
ConsoleIO.WriteLine(string.Format(Translations.mcc_help_us_translate, Settings.TranslationProjectUrl));
}
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>
internal static void HandleConfigLoadFailure()
{
ConsoleIO.Backend?.StopReadThread();
string command = " ";
while (command.Length > 0)
{
ConsoleIO.WriteLine(string.Empty);
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_invaild_config, Config.Main.Advanced.InternalCmdChar.ToLogString()));
if (ConsoleIO.Backend is Tui.TuiConsoleBackend)
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_use_quit_to_exit, Config.Main.Advanced.InternalCmdChar.ToLogString()));
else
ConsoleIO.WriteLineFormatted(Translations.mcc_press_exit, acceptnewlines: true);
command = ConsoleIO.ReadLine().Trim();
if (command.Length > 0)
{
if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' '
&& command[0] == Config.Main.Advanced.InternalCmdChar.ToChar())
command = command[1..];
if (command.StartsWith("exit") || command.StartsWith("quit"))
{
return;
}
else if (command.StartsWith("new"))
{
Config.Main.Advanced.Language = Settings.GetDefaultGameLanguage();
WriteBackSettings(true);
ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_gen_new_config, settingsIniPath));
return;
}
}
else
{
return;
}
}
}
/// <summary>
/// Runs the main startup sequence: CLI argument processing, auth, and connection.
/// Called from Main() for classic/basic mode, or from TuiConsoleBackend on a
/// background thread after the Avalonia UI loop has started.
/// </summary>
internal static void RunStartupSequence(string[] args)
{
//Other command-line arguments
if (args.Length >= 1)
@ -732,7 +870,8 @@ namespace MinecraftClient
/// </summary>
public static void ReloadSettings(bool keepAccountAndServerSettings = false)
{
if (Settings.LoadFromFile(settingsIniPath, keepAccountAndServerSettings).Item1)
var result = Settings.LoadFromFile(settingsIniPath, keepAccountAndServerSettings);
if (result.Success)
ConsoleIO.WriteLine(string.Format(Translations.config_load, settingsIniPath));
}
@ -774,16 +913,20 @@ namespace MinecraftClient
public static void DoExit(int exitcode = 0)
{
WriteBackSettings();
ConsoleIO.Backend?.Shutdown();
ConsoleIO.WriteLineFormatted("§a" + string.Format(Translations.config_saving, settingsIniPath));
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
if (offlinePrompt is not null)
{
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
offlinePrompt.Item2.Cancel(); offlinePrompt.Item1.Join(); offlinePrompt = null; ConsoleIO.Reset();
offlinePrompt.Item2.Cancel();
if (Thread.CurrentThread != offlinePrompt.Item1)
offlinePrompt.Item1.Join(1000);
offlinePrompt = null;
ConsoleIO.Reset();
}
if (Config.Main.Advanced.PlayerHeadAsIcon) { ConsoleIcon.RevertToMCCIcon(); }
ConsoleIO.Backend?.Shutdown();
Environment.Exit(exitcode);
}
@ -804,15 +947,18 @@ namespace MinecraftClient
/// <param name="disconnectReason">If set, the error message will be processed by the AutoRelog bot</param>
public static void HandleFailure(string? errorMessage = null, bool versionError = false, ChatBot.DisconnectReason? disconnectReason = null)
{
if (!String.IsNullOrEmpty(errorMessage))
if (!string.IsNullOrEmpty(errorMessage))
{
ConsoleIO.Reset();
try
if (ConsoleIO.Backend is not Tui.TuiConsoleBackend)
{
while (Console.KeyAvailable)
Console.ReadKey(true);
try
{
while (Console.KeyAvailable)
Console.ReadKey(true);
}
catch { }
}
catch { }
ConsoleIO.WriteLine(errorMessage);
if (disconnectReason.HasValue)
@ -864,65 +1010,57 @@ namespace MinecraftClient
if (exitThread)
return;
while (command.Length > 0)
command = ConsoleIO.ReadLine().Trim();
if (command.Length == 0)
{
if (cancellationTokenSource.IsCancellationRequested)
return;
command = ConsoleIO.ReadLine().Trim();
if (command.Length > 0)
{
string message = "";
if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' '
&& command[0] == Config.Main.Advanced.InternalCmdChar.ToChar())
command = command[1..];
if (command.StartsWith("reco"))
{
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
if (message == "")
{
exitThread = true;
break;
}
}
else if (command.StartsWith("connect"))
{
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
if (message == "")
{
exitThread = true;
break;
}
}
else if (command.StartsWith("exit") || command.StartsWith("quit"))
{
message = Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
}
else if (command.StartsWith("help"))
{
ConsoleIO.WriteLineFormatted("§8MCC: " +
Config.Main.Advanced.InternalCmdChar.ToLogString() +
new Commands.Reco().GetCmdDescTranslated());
ConsoleIO.WriteLineFormatted("§8MCC: " +
Config.Main.Advanced.InternalCmdChar.ToLogString() +
new Commands.Connect().GetCmdDescTranslated());
}
else
ConsoleIO.WriteLineFormatted(string.Format(Translations.icmd_unknown, command.Split(' ')[0]));
if (message != "")
ConsoleIO.WriteLineFormatted("§8MCC: " + message);
}
else
{
if (ConsoleIO.Backend is not Tui.TuiConsoleBackend)
Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
}
continue;
}
if (exitThread)
return;
string message = "";
if (Config.Main.Advanced.InternalCmdChar.ToChar() != ' '
&& command[0] == Config.Main.Advanced.InternalCmdChar.ToChar())
command = command[1..];
if (command.StartsWith("reco"))
{
message = Commands.Reco.DoReconnect(Config.AppVar.ExpandVars(command));
if (message == "")
{
exitThread = true;
continue;
}
}
else if (command.StartsWith("connect"))
{
message = Commands.Connect.DoConnect(Config.AppVar.ExpandVars(command));
if (message == "")
{
exitThread = true;
continue;
}
}
else if (command.StartsWith("exit") || command.StartsWith("quit"))
{
message = Commands.Exit.DoExit(Config.AppVar.ExpandVars(command));
}
else if (command.StartsWith("help"))
{
ConsoleIO.WriteLineFormatted("§8MCC: " +
Config.Main.Advanced.InternalCmdChar.ToLogString() +
new Commands.Reco().GetCmdDescTranslated());
ConsoleIO.WriteLineFormatted("§8MCC: " +
Config.Main.Advanced.InternalCmdChar.ToLogString() +
new Commands.Connect().GetCmdDescTranslated());
}
else
ConsoleIO.WriteLineFormatted(string.Format(Translations.icmd_unknown, command.Split(' ')[0]));
if (message != "")
ConsoleIO.WriteLineFormatted("§8MCC: " + message);
}
})), cancellationTokenSource);
offlinePrompt.Item1.Start();

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>
@ -1715,16 +1772,15 @@ namespace MinecraftClient.Protocol.Handlers
public byte[] GetLocation(Location location)
{
byte[] locationBytes;
ulong x = (ulong)(int)Math.Floor(location.X) & 0x3FFFFFF;
ulong y = (ulong)(int)Math.Floor(location.Y) & 0xFFF;
ulong z = (ulong)(int)Math.Floor(location.Z) & 0x3FFFFFF;
if (protocolversion >= Protocol18Handler.MC_1_14_Version)
{
locationBytes = BitConverter.GetBytes(((((ulong)location.X) & 0x3FFFFFF) << 38) |
((((ulong)location.Z) & 0x3FFFFFF) << 12) |
(((ulong)location.Y) & 0xFFF));
locationBytes = BitConverter.GetBytes((x << 38) | (z << 12) | y);
}
else
locationBytes = BitConverter.GetBytes(((((ulong)location.X) & 0x3FFFFFF) << 38) |
((((ulong)location.Y) & 0xFFF) << 26) |
(((ulong)location.Z) & 0x3FFFFFF));
locationBytes = BitConverter.GetBytes((x << 38) | (y << 26) | z);
Array.Reverse(locationBytes); //Endianness
return locationBytes;

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>
@ -434,6 +455,19 @@ namespace MinecraftClient.Protocol
/// <param name="factorCodec">factorCodec</param>
void OnEntityEffect(int entityid, Effects effect, int amplifier, int duration, byte flags, bool hasFactorData, Dictionary<String, object>? factorCodec);
/// <summary>
/// Called when an entity has an effect removed
/// </summary>
/// <param name="entityid">Entity ID</param>
/// <param name="effect">Effect that was removed</param>
void OnRemoveEntityEffect(int entityid, Effects effect);
/// <summary>
/// Get the player's active effects
/// </summary>
/// <returns>Dictionary of active effects</returns>
Dictionary<Effects, EffectData> GetPlayerEffects();
/// <summary>
/// Called when Soreboard Objective
/// </summary>
@ -455,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>
@ -504,6 +555,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.
@ -705,6 +735,9 @@ Usage examples: "/tell &lt;mybot&gt; connect Server1", "/connect Server2"</value
<data name="Main.Advanced.show_inventory_layout" xml:space="preserve">
<value>Show inventory layout as ASCII art in inventory command.</value>
</data>
<data name="Main.Advanced.show_effect_names_in_tui" xml:space="preserve">
<value>Show full effect names and levels in the TUI status bar instead of compact effect icons only.</value>
</data>
<data name="Main.Advanced.show_system_messages" xml:space="preserve">
<value>System messages for server ops.</value>
</data>
@ -930,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>
@ -3522,6 +3630,87 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to list your currently active effects..
/// </summary>
internal static string cmd_effects_desc {
get {
return ResourceManager.GetString("cmd.effects.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to - {0} ({1}).
/// </summary>
internal static string cmd_effects_entry {
get {
return ResourceManager.GetString("cmd.effects.entry", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Active effects:.
/// </summary>
internal static string cmd_effects_header {
get {
return ResourceManager.GetString("cmd.effects.header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No active effects..
/// </summary>
internal static string cmd_effects_none {
get {
return ResourceManager.GetString("cmd.effects.none", resourceCulture);
}
}
/// <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>
@ -4218,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..
@ -4426,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>
@ -4445,7 +4760,7 @@ namespace MinecraftClient {
}
/// <summary>
/// Looks up a localized string similar to Use (left click) an item on the hand.
/// Looks up a localized string similar to Use the item in your hand, optionally on a specific block.
/// </summary>
internal static string cmd_useitem_desc {
get {
@ -5498,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>
@ -5825,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>
@ -6494,6 +6854,15 @@ namespace MinecraftClient {
}
}
/// <summary>
/// Looks up a localized string similar to Durability.
/// </summary>
internal static string tui_inventory_durability {
get {
return ResourceManager.GetString("tui.inventory.durability", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Container not found.
/// </summary>
@ -6520,5 +6889,509 @@ namespace MinecraftClient {
return ResourceManager.GetString("tui.inventory.item_count", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You&apos;re now under {0} effect (Duration: {1})..
/// </summary>
internal static string bot_effect_gained {
get {
return ResourceManager.GetString("bot.effect.gained", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Effect {0} has expired.
/// </summary>
internal static string bot_effect_expired {
get {
return ResourceManager.GetString("bot.effect.expired", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unlimited.
/// </summary>
internal static string effect_duration_unlimited {
get {
return ResourceManager.GetString("effect.duration.unlimited", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to a.
/// </summary>
internal static string effect_article_a {
get {
return ResourceManager.GetString("effect.article.a", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to an.
/// </summary>
internal static string effect_article_an {
get {
return ResourceManager.GetString("effect.article.an", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}h.
/// </summary>
internal static string effect_duration_short_hours {
get {
return ResourceManager.GetString("effect.duration.short.hours", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}h {1}m.
/// </summary>
internal static string effect_duration_short_hours_minutes {
get {
return ResourceManager.GetString("effect.duration.short.hours_minutes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}m.
/// </summary>
internal static string effect_duration_short_minutes {
get {
return ResourceManager.GetString("effect.duration.short.minutes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}m {1}s.
/// </summary>
internal static string effect_duration_short_minutes_seconds {
get {
return ResourceManager.GetString("effect.duration.short.minutes_seconds", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}s.
/// </summary>
internal static string effect_duration_short_seconds {
get {
return ResourceManager.GetString("effect.duration.short.seconds", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to ∞.
/// </summary>
internal static string effect_duration_short_unlimited {
get {
return ResourceManager.GetString("effect.duration.short.unlimited", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} {1}.
/// </summary>
internal static string effect_name_with_amplifier {
get {
return ResourceManager.GetString("effect.name.with_amplifier", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Container.
/// </summary>
internal static string tui_container_label {
get {
return ResourceManager.GetString("tui.container.label", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Input.
/// </summary>
internal static string tui_furnace_input {
get {
return ResourceManager.GetString("tui.furnace.input", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fuel.
/// </summary>
internal static string tui_furnace_fuel {
get {
return ResourceManager.GetString("tui.furnace.fuel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Output.
/// </summary>
internal static string tui_furnace_output {
get {
return ResourceManager.GetString("tui.furnace.output", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Item.
/// </summary>
internal static string tui_enchanting_item {
get {
return ResourceManager.GetString("tui.enchanting.item", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Lapis.
/// </summary>
internal static string tui_enchanting_lapis {
get {
return ResourceManager.GetString("tui.enchanting.lapis", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Enchant Options.
/// </summary>
internal static string tui_enchanting_options {
get {
return ResourceManager.GetString("tui.enchanting.options", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Option {0}.
/// </summary>
internal static string tui_enchanting_option_slot {
get {
return ResourceManager.GetString("tui.enchanting.option_slot", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fuel.
/// </summary>
internal static string tui_brewing_fuel {
get {
return ResourceManager.GetString("tui.brewing.fuel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Ingredient.
/// </summary>
internal static string tui_brewing_ingredient {
get {
return ResourceManager.GetString("tui.brewing.ingredient", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Bottle {0}.
/// </summary>
internal static string tui_brewing_bottle {
get {
return ResourceManager.GetString("tui.brewing.bottle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Input 1.
/// </summary>
internal static string tui_grindstone_input1 {
get {
return ResourceManager.GetString("tui.grindstone.input1", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Input 2.
/// </summary>
internal static string tui_grindstone_input2 {
get {
return ResourceManager.GetString("tui.grindstone.input2", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Crafting.
/// </summary>
internal static string tui_crafting_grid {
get {
return ResourceManager.GetString("tui.crafting.grid", 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>
@ -1237,6 +1285,33 @@ Change EnableEmoji=false in the settings if the display is confusing.</value>
<data name="cmd.follow.usage" xml:space="preserve">
<value>follow &lt;player name|stop&gt; [-f] (Use -f to enable un-safe walking)</value>
</data>
<data name="cmd.effects.desc" xml:space="preserve">
<value>list your currently active effects.</value>
</data>
<data name="cmd.effects.entry" xml:space="preserve">
<value>- {0} ({1})</value>
</data>
<data name="cmd.effects.header" xml:space="preserve">
<value>Active effects:</value>
</data>
<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>
@ -1499,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>
@ -1506,7 +1596,7 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s
<value>Useblock at ({0:0.0}, {1:0.0}, {2:0.0}) {3}.</value>
</data>
<data name="cmd.useitem.desc" xml:space="preserve">
<value>Use (left click) an item on the hand</value>
<value>Use the item in your hand, optionally on a specific block</value>
</data>
<data name="cmd.useitem.use" xml:space="preserve">
<value>Used an item</value>
@ -1851,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>
@ -1962,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>
@ -1976,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>
@ -2139,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>
@ -2290,6 +2422,9 @@ see item details.</value>
<data name="tui.inventory.slot_detail" xml:space="preserve">
<value>Slot #{0} Count: {1}</value>
</data>
<data name="tui.inventory.durability" xml:space="preserve">
<value>Durability</value>
</data>
<data name="tui.inventory.container_not_found" xml:space="preserve">
<value>Container not found</value>
</data>
@ -2299,4 +2434,172 @@ see item details.</value>
<data name="tui.inventory.item_count" xml:space="preserve">
<value>{0} items</value>
</data>
</root>
<data name="bot.effect.gained" xml:space="preserve">
<value>You're now under {0} effect (Duration: {1}).</value>
</data>
<data name="bot.effect.expired" xml:space="preserve">
<value>Effect {0} has expired</value>
</data>
<data name="effect.duration.unlimited" xml:space="preserve">
<value>Unlimited</value>
</data>
<data name="effect.article.a" xml:space="preserve">
<value>a</value>
</data>
<data name="effect.article.an" xml:space="preserve">
<value>an</value>
</data>
<data name="effect.duration.short.hours" xml:space="preserve">
<value>{0}h</value>
</data>
<data name="effect.duration.short.hours_minutes" xml:space="preserve">
<value>{0}h {1}m</value>
</data>
<data name="effect.duration.short.minutes" xml:space="preserve">
<value>{0}m</value>
</data>
<data name="effect.duration.short.minutes_seconds" xml:space="preserve">
<value>{0}m {1}s</value>
</data>
<data name="effect.duration.short.seconds" xml:space="preserve">
<value>{0}s</value>
</data>
<data name="effect.duration.short.unlimited" xml:space="preserve">
<value>∞</value>
</data>
<data name="effect.name.with_amplifier" xml:space="preserve">
<value>{0} {1}</value>
</data>
<data name="tui.container.label" xml:space="preserve">
<value>Container</value>
</data>
<data name="tui.furnace.input" xml:space="preserve">
<value>Input</value>
</data>
<data name="tui.furnace.fuel" xml:space="preserve">
<value>Fuel</value>
</data>
<data name="tui.furnace.output" xml:space="preserve">
<value>Output</value>
</data>
<data name="tui.enchanting.item" xml:space="preserve">
<value>Item</value>
</data>
<data name="tui.enchanting.lapis" xml:space="preserve">
<value>Lapis</value>
</data>
<data name="tui.enchanting.options" xml:space="preserve">
<value>Enchant Options</value>
</data>
<data name="tui.enchanting.option_slot" xml:space="preserve">
<value>Option {0}</value>
</data>
<data name="tui.brewing.fuel" xml:space="preserve">
<value>Fuel</value>
</data>
<data name="tui.brewing.ingredient" xml:space="preserve">
<value>Ingredient</value>
</data>
<data name="tui.brewing.bottle" xml:space="preserve">
<value>Bottle {0}</value>
</data>
<data name="tui.grindstone.input1" xml:space="preserve">
<value>Input 1</value>
</data>
<data name="tui.grindstone.input2" xml:space="preserve">
<value>Input 2</value>
</data>
<data name="tui.crafting.grid" xml:space="preserve">
<value>Crafting</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

@ -199,6 +199,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>
@ -333,6 +356,13 @@ namespace MinecraftClient.Scripting
/// <param name="flags">effect flags</param>
public virtual void OnEntityEffect(Entity entity, Effects effect, int amplifier, int duration, byte flags) { }
/// <summary>
/// Called when an entity has an effect removed (expired or cleared)
/// </summary>
/// <param name="entity">Entity</param>
/// <param name="effect">Effect that was removed</param>
public virtual void OnRemoveEntityEffect(Entity entity, Effects effect) { }
/// <summary>
/// Called when a scoreboard objective updated
/// </summary>
@ -354,6 +384,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>
@ -507,6 +554,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. */
@ -1082,9 +1137,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>
@ -1113,6 +1169,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

@ -136,7 +136,22 @@ namespace MinecraftClient
}
public static Tuple<bool, bool> LoadFromFile(string filepath, bool keepAccountAndServerSettings = false)
/// <summary>
/// Structured result returned by <see cref="LoadFromFile(string, bool)"/>.
/// </summary>
public readonly struct ConfigLoadResult
{
public bool Success { get; init; }
public bool NeedWriteDefault { get; init; }
/// <summary>True when a pre-TOML legacy config was detected, backed up, and a fresh default is needed.</summary>
public bool IsLegacyUpgrade { get; init; }
/// <summary>Non-null when the load failed due to a parse/IO error (not a legacy upgrade).</summary>
public string? ErrorMessage { get; init; }
/// <summary>Path where the old config was backed up (legacy upgrade case).</summary>
public string? LegacyBackupPath { get; init; }
}
public static ConfigLoadResult LoadFromFile(string filepath, bool keepAccountAndServerSettings = false)
{
bool keepAccountSettings = InternalConfig.KeepAccountSettings;
bool keepServerSettings = InternalConfig.KeepServerSettings;
@ -157,21 +172,27 @@ namespace MinecraftClient
Thread.CurrentThread.CurrentCulture = Program.ActualCulture;
try
{
// The old configuration file has been backed up as A.
string configString = File.ReadAllText(filepath);
if (configString.Contains("Some settings missing here after an upgrade?"))
{
string newFilePath = Path.ChangeExtension(filepath, ".old.ini");
File.Copy(filepath, newFilePath, true);
ConsoleIO.WriteLineFormatted("§c" + Translations.mcc_use_new_config);
ConsoleIO.WriteLineFormatted("§c" + string.Format(Translations.mcc_backup_old_config, newFilePath));
return new(false, true);
return new ConfigLoadResult
{
Success = false,
NeedWriteDefault = true,
IsLegacyUpgrade = true,
LegacyBackupPath = newFilePath
};
}
}
catch { }
ConsoleIO.WriteLineFormatted("§c" + Translations.config_load_fail);
ConsoleIO.WriteLine(ex.GetFullMessage());
return new(false, false);
return new ConfigLoadResult
{
Success = false,
NeedWriteDefault = false,
ErrorMessage = ex.GetFullMessage()
};
}
finally
{
@ -180,7 +201,7 @@ namespace MinecraftClient
if (!keepServerSettings)
InternalConfig.KeepServerSettings = false;
}
return new(true, false);
return new ConfigLoadResult { Success = true, NeedWriteDefault = false };
}
public static void WriteToFile(string filepath, bool backupOldFile)
@ -796,6 +817,9 @@ namespace MinecraftClient
[TomlInlineComment("$Main.Advanced.show_inventory_layout$")]
public bool ShowInventoryLayout = true;
[TomlInlineComment("$Main.Advanced.show_effect_names_in_tui$")]
public bool ShowEffectNamesInTUI = false;
[TomlInlineComment("$Main.Advanced.terrain_and_movements$")]
public bool TerrainAndMovements = false;
@ -1084,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;
@ -1183,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]
@ -1222,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,152 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class BrewingStandView : ContainerViewBase
{
private readonly BrewingViewModel _brewVm;
public BrewingStandView(McClient handler, int windowId)
: base(new BrewingViewModel(handler, windowId))
{
_brewVm = (BrewingViewModel)_vm;
Initialize();
}
protected override int GetTotalSlotRows()
{
return 3 + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
var panel = new StackPanel
{
Spacing = 0,
HorizontalAlignment = HorizontalAlignment.Center,
};
var topRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Spacing = 0,
};
var fuelCol = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
};
fuelCol.Children.Add(new TextBlock
{
Text = Translations.tui_brewing_fuel,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
fuelCol.Children.Add(CreateSlotCell(_brewVm.FuelSlot, 0, 0));
topRow.Children.Add(fuelCol);
topRow.Children.Add(new Border { Width = 2 });
var ingredientCol = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
};
ingredientCol.Children.Add(new TextBlock
{
Text = Translations.tui_brewing_ingredient,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
ingredientCol.Children.Add(CreateSlotCell(_brewVm.IngredientSlot, 0, 1));
topRow.Children.Add(ingredientCol);
panel.Children.Add(topRow);
panel.Children.Add(new TextBlock
{
Text = "\u25bc",
Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)),
HorizontalAlignment = HorizontalAlignment.Center,
});
var bottleRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Spacing = 0,
};
for (int i = 0; i < 3; i++)
{
var bottlePanel = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
};
bottlePanel.Children.Add(new TextBlock
{
Text = string.Format(Translations.tui_brewing_bottle, i + 1),
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
bottlePanel.Children.Add(CreateSlotCell(_brewVm.BottleSlots[i], 1, i));
bottleRow.Children.Add(bottlePanel);
}
panel.Children.Add(bottleRow);
return panel;
}
}
public class BrewingViewModel : ContainerViewModel
{
public ObservableCollection<SlotViewModel> BottleSlots { get; } = new();
public SlotViewModel IngredientSlot { get; private set; } = null!;
public SlotViewModel FuelSlot { get; private set; } = null!;
public BrewingViewModel(McClient handler, int windowId)
: base(handler, windowId, ContainerType.BrewingStand)
{
IngredientSlot = SlotMap[3];
FuelSlot = SlotMap[4];
}
protected override void InitializeSlots()
{
SlotMap.Clear();
for (int i = 0; i <= 2; i++)
{
var slot = new SlotViewModel(i);
BottleSlots.Add(slot);
SlotMap[i] = slot;
}
SlotMap[3] = new SlotViewModel(3);
SlotMap[4] = new SlotViewModel(4);
for (int i = 5; i <= 31; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = 32; i <= 40; i++)
{
int hotbarIdx = i - 32;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
SlotMap[i] = slot;
}
}
}
}

View file

@ -0,0 +1,730 @@
using System;
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public abstract class ContainerViewBase : UserControl
{
protected static readonly IBrush BrSlotEmptyA = new SolidColorBrush(Color.FromRgb(40, 40, 40));
protected static readonly IBrush BrSlotEmptyB = new SolidColorBrush(Color.FromRgb(55, 55, 55));
protected static readonly IBrush BrSlotFillA = new SolidColorBrush(Color.FromRgb(60, 60, 75));
protected static readonly IBrush BrSlotFillB = new SolidColorBrush(Color.FromRgb(75, 75, 90));
protected static readonly IBrush BrSlotHover = new SolidColorBrush(Color.FromRgb(100, 100, 140));
protected static readonly IBrush BrName = Brushes.White;
protected static readonly IBrush BrCount = Brushes.Yellow;
protected static readonly IBrush BrDim = new SolidColorBrush(Color.FromRgb(80, 80, 80));
protected static readonly IBrush BrEquipLbl = Brushes.DarkCyan;
protected static readonly IBrush BrInfoHighlight = new SolidColorBrush(Color.FromRgb(40, 40, 60));
protected static readonly IBrush BrHeldItemBg = new SolidColorBrush(Color.FromRgb(60, 50, 80));
protected static readonly IBrush BrHeldItemBorder = Brushes.Yellow;
protected int _slotW;
protected int _slotH;
protected int _nameMaxLen;
protected int _nameLines;
protected int _termW;
protected readonly ContainerViewModel _vm;
protected TextBlock _titleText = null!;
protected Border _infoDetailBorder = null!;
protected TextBlock _infoDetailText = null!;
protected TextBlock _cursorItemText = null!;
protected TextBlock _helpText = null!;
protected TextBlock[] _hotbarIndicators = new TextBlock[9];
protected int _currentHotbarSlot = -1;
protected Border? _lastHoveredSlotBorder;
protected Canvas _overlayCanvas = null!;
protected Border _heldItemFloater = null!;
protected TextBlock _heldItemFloaterName = null!;
protected TextBlock _heldItemFloaterCount = null!;
protected ScrollViewer _chatScrollViewer = null!;
protected ObservableCollection<string>? _chatLines;
protected int _lastTermW;
protected int _lastTermH;
protected bool _chatScrollToBottom = true;
protected ContainerViewBase(ContainerViewModel vm)
{
_vm = vm;
_currentHotbarSlot = vm.Handler.GetCurrentSlot();
_chatLines = TuiConsoleBackend.Instance?.GetView()?.GetRecentLogLines(50)
?? new ObservableCollection<string>();
}
protected void Initialize()
{
RebuildUi();
}
protected abstract int GetTotalSlotRows();
protected abstract Control BuildContainerSpecificArea();
protected virtual void OnContainerDataChanged() { }
protected virtual void RebuildUi()
{
int termH;
try
{
_termW = System.Console.WindowWidth;
termH = System.Console.WindowHeight;
}
catch
{
_termW = 120;
termH = 40;
}
_lastTermW = _termW;
_lastTermH = termH;
int availW = _termW - 26;
_slotW = Math.Clamp(availW / 9, 8, 18);
_nameMaxLen = _slotW;
int totalRows = GetTotalSlotRows();
int overhead = 4;
int chatMinH = 1;
_slotH = Math.Clamp((termH - overhead - chatMinH) / totalRows, 2, 5);
_nameLines = _slotH;
_vm.SetSlotDisplayParams(_nameMaxLen, _nameLines);
_lastHoveredSlotBorder = null;
_titleText = new TextBlock
{
FontWeight = FontWeight.Bold,
Foreground = Brushes.Cyan,
HorizontalAlignment = HorizontalAlignment.Center,
};
_infoDetailText = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Foreground = Brushes.White,
};
_infoDetailBorder = new Border
{
Background = Brushes.Transparent,
Padding = new Thickness(0),
Child = _infoDetailText,
};
_cursorItemText = new TextBlock
{
Foreground = Brushes.Yellow,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap,
};
_helpText = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)),
Text = Translations.tui_inventory_controls_help,
};
_heldItemFloaterName = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap,
};
_heldItemFloaterCount = new TextBlock
{
Foreground = BrCount,
FontWeight = FontWeight.Bold,
};
_heldItemFloater = new Border
{
Background = BrHeldItemBg,
BorderBrush = BrHeldItemBorder,
BorderThickness = new Thickness(1),
Padding = new Thickness(1, 0),
IsVisible = false,
MaxWidth = 24,
Child = new StackPanel
{
Children = { _heldItemFloaterName, _heldItemFloaterCount },
},
};
_overlayCanvas = new Canvas { IsHitTestVisible = false };
_overlayCanvas.Children.Add(_heldItemFloater);
var chatLines = _chatLines!;
chatLines.CollectionChanged += (_, _) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var sv = _chatScrollViewer;
if (sv.Extent.Height > sv.Viewport.Height)
sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
}, Avalonia.Threading.DispatcherPriority.Background);
};
var chatItemsControl = new ItemsControl
{
ItemsSource = chatLines,
Focusable = false,
ItemTemplate = new FuncDataTemplate<string>((s, _) =>
new TextBlock
{
Text = s,
Foreground = Brushes.Gray,
Padding = new Thickness(0),
Margin = new Thickness(0),
TextWrapping = TextWrapping.Wrap,
}),
};
_chatScrollViewer = new ScrollViewer
{
Content = chatItemsControl,
Background = Brushes.Black,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
Padding = new Thickness(0),
};
_hotbarIndicators = new TextBlock[9];
Content = BuildRootLayout();
UpdateTitle();
UpdateInfoPanel();
_chatScrollToBottom = true;
_chatScrollViewer.ScrollChanged += OnChatScrollChanged;
}
private void OnChatScrollChanged(object? sender, ScrollChangedEventArgs e)
{
if (!_chatScrollToBottom) return;
var sv = _chatScrollViewer;
if (sv.Extent.Height > sv.Viewport.Height)
{
sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
_chatScrollToBottom = false;
}
}
protected virtual Control BuildRootLayout()
{
var inventoryArea = BuildMainArea();
DockPanel.SetDock(_titleText, Dock.Top);
DockPanel.SetDock(inventoryArea, Dock.Top);
var mainContent = new DockPanel
{
Children = { _titleText, inventoryArea, _chatScrollViewer }
};
return new Panel
{
Background = Brushes.Black,
Children = { mainContent, _overlayCanvas }
};
}
protected virtual Control BuildMainArea()
{
var infoPanel = BuildInfoPanel();
DockPanel.SetDock(infoPanel, Dock.Right);
return new DockPanel
{
Children = { infoPanel, BuildInventoryPanel() }
};
}
protected virtual Control BuildInventoryPanel()
{
var root = new StackPanel
{
Spacing = 0,
HorizontalAlignment = HorizontalAlignment.Center,
};
root.Children.Add(BuildContainerSpecificArea());
root.Children.Add(BuildSeparator());
root.Children.Add(BuildSlotGrid(_vm.MainInventorySlots, 9));
root.Children.Add(BuildHotbarSection());
return new Border
{
BorderThickness = new Thickness(1),
BorderBrush = Brushes.Gray,
Child = root,
};
}
protected Control BuildSeparator()
{
return new Border
{
Height = 1,
Background = Brushes.Transparent,
Margin = new Thickness(0, 0, 0, 0),
};
}
protected Control BuildInfoPanel()
{
return new Border
{
BorderThickness = new Thickness(1),
BorderBrush = Brushes.Gray,
Padding = new Thickness(1),
Width = 24,
Child = new StackPanel
{
Children =
{
new TextBlock { Text = Translations.tui_inventory_item_info, FontWeight = FontWeight.Bold, Foreground = Brushes.Cyan },
_infoDetailBorder,
new TextBlock { Text = Translations.tui_inventory_held_item, FontWeight = FontWeight.Bold, Foreground = Brushes.Yellow, Margin = new Thickness(0, 1, 0, 0) },
_cursorItemText,
new TextBlock { Text = Translations.tui_inventory_controls, FontWeight = FontWeight.Bold, Foreground = Brushes.Green, Margin = new Thickness(0, 1, 0, 0) },
_helpText,
}
}
};
}
protected Control BuildHotbarSection()
{
var panel = new StackPanel { Spacing = 0 };
var numberRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
};
for (int i = 0; i < 9; i++)
{
bool active = i == _currentHotbarSlot;
string label = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
var tb = new TextBlock
{
Text = label,
Width = _slotW,
TextAlignment = TextAlignment.Center,
Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan,
FontWeight = FontWeight.Bold,
};
_hotbarIndicators[i] = tb;
numberRow.Children.Add(tb);
}
panel.Children.Add(numberRow);
panel.Children.Add(BuildSlotGrid(_vm.HotbarSlots, 9));
return panel;
}
protected Control BuildSlotGrid(ObservableCollection<SlotViewModel> slots, int columns)
{
var grid = new Grid();
int rows = (slots.Count + columns - 1) / columns;
for (int r = 0; r < rows; r++)
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
for (int c = 0; c < columns; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
for (int i = 0; i < slots.Count; i++)
{
int row = i / columns;
int col = i % columns;
var cell = CreateSlotCell(slots[i], row, col);
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
grid.Children.Add(cell);
}
return grid;
}
protected static IBrush GetSlotBg(bool isEmpty, int row, int col)
{
bool isA = (row + col) % 2 == 0;
return isEmpty
? (isA ? BrSlotEmptyA : BrSlotEmptyB)
: (isA ? BrSlotFillA : BrSlotFillB);
}
protected Border CreateSlotCell(SlotViewModel slot, int row = 0, int col = 0)
{
var nameTb = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(0),
Margin = new Thickness(0),
VerticalAlignment = VerticalAlignment.Top,
};
var countTb = new TextBlock
{
Foreground = BrCount,
FontWeight = FontWeight.Bold,
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Bottom,
};
ApplySlotVisual(slot, nameTb, countTb);
int r = row, c = col;
var border = new Border
{
Width = _slotW,
Height = _slotH,
Background = GetSlotBg(slot.IsEmpty, r, c),
Child = new Panel
{
Children = { nameTb, countTb },
},
Tag = (slot, r, c),
};
border.PointerPressed += OnSlotPointerPressed;
border.PointerEntered += OnSlotPointerEnter;
border.PointerExited += OnSlotPointerExit;
border.PointerMoved += OnSlotPointerMoved;
slot.PropertyChanged += (_, _) =>
{
ApplySlotVisual(slot, nameTb, countTb);
border.Background = GetSlotBg(slot.IsEmpty, r, c);
};
return border;
}
protected static void ApplySlotVisual(SlotViewModel slot, TextBlock nameTb, TextBlock countTb)
{
if (slot.IsEmpty)
{
nameTb.Text = "";
nameTb.Foreground = BrDim;
countTb.Text = "";
}
else
{
nameTb.Text = slot.ItemDisplayText;
nameTb.Foreground = BrName;
countTb.Text = slot.CountDisplay;
}
}
protected TextBlock MakeLabel(string text)
{
return new TextBlock
{
Text = text,
Foreground = BrEquipLbl,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(1, 0, 0, 0),
FontWeight = FontWeight.Bold,
};
}
#region Pointer / Keyboard interaction
private void OnSlotPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Border border || border.Tag is not (SlotViewModel slot, int, int))
return;
SetHover(border, slot);
var point = e.GetCurrentPoint(border);
bool isShift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
WindowActionType action;
if (point.Properties.IsRightButtonPressed)
action = isShift ? WindowActionType.ShiftRightClick : WindowActionType.RightClick;
else
action = isShift ? WindowActionType.ShiftClick : WindowActionType.LeftClick;
_vm.PerformAction(slot.SlotId, action);
UpdateInfoPanel();
UpdateHeldItemFloater(e);
OnContainerDataChanged();
e.Handled = true;
}
private void OnSlotPointerEnter(object? sender, PointerEventArgs e)
{
if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
{
SetHover(b, slot);
UpdateHeldItemFloater(e);
}
}
private void OnSlotPointerMoved(object? sender, PointerEventArgs e)
{
if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
{
SetHover(b, slot);
UpdateHeldItemFloater(e);
}
}
private void OnSlotPointerExit(object? sender, PointerEventArgs e)
{
if (sender is Border b && b.Tag is (SlotViewModel slot, int row, int col))
b.Background = GetSlotBg(slot.IsEmpty, row, col);
}
protected void SetHover(Border border, SlotViewModel slot)
{
if (_lastHoveredSlotBorder != null && _lastHoveredSlotBorder != border)
{
if (_lastHoveredSlotBorder.Tag is (SlotViewModel oldSlot, int or, int oc))
_lastHoveredSlotBorder.Background = GetSlotBg(oldSlot.IsEmpty, or, oc);
}
_lastHoveredSlotBorder = border;
border.Background = BrSlotHover;
_vm.HoveredSlot = slot;
UpdateInfoPanel();
}
protected void UpdateHeldItemFloater(PointerEventArgs e)
{
if (!_vm.HasCursorItem)
{
_heldItemFloater.IsVisible = false;
return;
}
_heldItemFloaterName.Text = _vm.CursorItemInfo;
_heldItemFloaterCount.Text = "";
try
{
var pos = e.GetPosition(_overlayCanvas);
double left = pos.X + 2;
double remainingW = _termW - left - 2;
int maxW = Math.Max(8, (int)remainingW);
_heldItemFloater.MaxWidth = maxW;
Canvas.SetLeft(_heldItemFloater, left);
Canvas.SetTop(_heldItemFloater, pos.Y);
}
catch
{
_heldItemFloater.MaxWidth = 24;
Canvas.SetLeft(_heldItemFloater, 0);
Canvas.SetTop(_heldItemFloater, 0);
}
_heldItemFloater.IsVisible = true;
}
protected void UpdateInfoPanel()
{
_infoDetailText.Text = _vm.HoveredSlotDetailText;
bool hasHoveredItem = _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty;
_infoDetailBorder.Background = hasHoveredItem ? BrInfoHighlight : Brushes.Transparent;
if (_vm.HasCursorItem)
{
_cursorItemText.Text = _vm.CursorItemInfo;
_cursorItemText.Foreground = Brushes.Yellow;
}
else
{
_cursorItemText.Text = Translations.tui_inventory_cursor_empty;
_cursorItemText.Foreground = BrDim;
_heldItemFloater.IsVisible = false;
}
}
protected void UpdateTitle()
{
_titleText.Text = _vm.Title;
}
protected void CloseInventory()
{
if (_vm.WindowId != 0)
_vm.Handler.CloseInventory(_vm.WindowId);
if (ConsoleIO.Backend is TuiConsoleBackend tuiBackend)
tuiBackend.GetView()?.HideOverlay();
else
(Application.Current?.ApplicationLifetime as IControlledApplicationLifetime)?.Shutdown();
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
switch (e.Key)
{
case Key.Escape:
case Key.E:
CloseInventory();
e.Handled = true;
break;
case Key.C:
if ((e.KeyModifiers & KeyModifiers.Shift) != 0 &&
_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
{
_vm.PerformAction(_vm.HoveredSlot.SlotId, WindowActionType.ShiftClick);
UpdateInfoPanel();
}
e.Handled = true;
break;
case Key.Q:
if (_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
{
var action = (e.KeyModifiers & KeyModifiers.Control) != 0
? WindowActionType.DropItemStack
: WindowActionType.DropItem;
_vm.PerformAction(_vm.HoveredSlot.SlotId, action);
UpdateInfoPanel();
}
e.Handled = true;
break;
case Key.R:
_vm.RefreshFromContainer();
_currentHotbarSlot = _vm.Handler.GetCurrentSlot();
UpdateHotbarIndicators();
UpdateInfoPanel();
OnContainerDataChanged();
e.Handled = true;
break;
}
}
protected void UpdateHotbarIndicators()
{
for (int i = 0; i < 9; i++)
{
bool active = i == _currentHotbarSlot;
_hotbarIndicators[i].Text = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
_hotbarIndicators[i].Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan;
}
}
#endregion
#region Lifecycle
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
Focusable = true;
Focus();
AddHandler(KeyDownEvent, OnTunnelKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
SizeChanged += OnViewSizeChanged;
}
private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
CloseInventory();
e.Handled = true;
}
}
private void OnViewSizeChanged(object? sender, SizeChangedEventArgs e)
{
int newW, newH;
try
{
newW = System.Console.WindowWidth;
newH = System.Console.WindowHeight;
}
catch { return; }
if (newW == _lastTermW && newH == _lastTermH) return;
_vm.RefreshFromContainer();
_currentHotbarSlot = _vm.Handler.GetCurrentSlot();
RebuildUi();
Focus();
}
protected override void OnGotFocus(GotFocusEventArgs e)
{
base.OnGotFocus(e);
Focusable = true;
}
#endregion
public static bool HasTuiSupport(ContainerType type)
{
return type switch
{
ContainerType.PlayerInventory => true,
ContainerType.Generic_9x1 => true,
ContainerType.Generic_9x2 => true,
ContainerType.Generic_9x3 => true,
ContainerType.Generic_9x4 => true,
ContainerType.Generic_9x5 => true,
ContainerType.Generic_9x6 => true,
ContainerType.Generic_3x3 => true,
ContainerType.Crafter => true,
ContainerType.ShulkerBox => true,
ContainerType.Crafting => true,
ContainerType.Furnace => true,
ContainerType.BlastFurnace => true,
ContainerType.Smoker => true,
ContainerType.Enchantment => true,
ContainerType.BrewingStand => true,
ContainerType.Hopper => true,
ContainerType.Grindstone => true,
_ => false,
};
}
public static ContainerViewBase CreateView(ContainerType type, McClient handler, int windowId)
{
return type switch
{
ContainerType.PlayerInventory => new PlayerInventoryView(handler, windowId),
ContainerType.Generic_9x3 or ContainerType.ShulkerBox => new GridContainerView(handler, windowId, type, 3, 9),
ContainerType.Generic_9x6 => new GridContainerView(handler, windowId, type, 6, 9),
ContainerType.Generic_3x3 or ContainerType.Crafter
=> new GridContainerView(handler, windowId, type, 3, 3),
ContainerType.Generic_9x1 => new GridContainerView(handler, windowId, type, 1, 9),
ContainerType.Generic_9x2 => new GridContainerView(handler, windowId, type, 2, 9),
ContainerType.Generic_9x4 => new GridContainerView(handler, windowId, type, 4, 9),
ContainerType.Generic_9x5 => new GridContainerView(handler, windowId, type, 5, 9),
ContainerType.Crafting => new CraftingView(handler, windowId),
ContainerType.Furnace or ContainerType.BlastFurnace or ContainerType.Smoker
=> new FurnaceView(handler, windowId, type),
ContainerType.Enchantment => new EnchantingTableView(handler, windowId),
ContainerType.BrewingStand => new BrewingStandView(handler, windowId),
ContainerType.Hopper => new HopperView(handler, windowId),
ContainerType.Grindstone => new GrindstoneView(handler, windowId),
_ => throw new ArgumentException($"No TUI view for {type}"),
};
}
}
}

View file

@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using MinecraftClient.Inventory;
using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6;
namespace MinecraftClient.Tui
{
public class ContainerViewModel : INotifyPropertyChanged
{
private SlotViewModel? _hoveredSlot;
private string _title = "";
private string _statusText = "";
private string _cursorItemInfo = "";
private bool _hasCursorItem;
public McClient Handler { get; }
public int WindowId { get; }
public ContainerType ContainerType { get; }
public ObservableCollection<SlotViewModel> ContainerSlots { get; } = new();
public ObservableCollection<SlotViewModel> MainInventorySlots { get; } = new();
public ObservableCollection<SlotViewModel> HotbarSlots { get; } = new();
public string Title
{
get => _title;
set { _title = value; OnPropertyChanged(); }
}
public string StatusText
{
get => _statusText;
set { _statusText = value; OnPropertyChanged(); }
}
public string CursorItemInfo
{
get => _cursorItemInfo;
set { _cursorItemInfo = value; OnPropertyChanged(); }
}
public bool HasCursorItem
{
get => _hasCursorItem;
set { _hasCursorItem = value; OnPropertyChanged(); }
}
public SlotViewModel? HoveredSlot
{
get => _hoveredSlot;
set
{
if (_hoveredSlot != null)
_hoveredSlot.IsHovered = false;
_hoveredSlot = value;
if (_hoveredSlot != null)
_hoveredSlot.IsHovered = true;
OnPropertyChanged();
OnPropertyChanged(nameof(HoveredSlotDetailText));
}
}
public string HoveredSlotDetailText
{
get
{
if (_hoveredSlot == null)
return Translations.tui_inventory_hover_hint;
if (_hoveredSlot.IsEmpty)
return $"Slot #{_hoveredSlot.SlotId}\n{Translations.tui_inventory_slot_empty}";
var sb = new StringBuilder();
sb.AppendLine(_hoveredSlot.ItemTypeName);
sb.AppendLine(string.Format(Translations.tui_inventory_slot_detail, _hoveredSlot.SlotId, _hoveredSlot.ItemCount));
var item = _hoveredSlot.RawItem;
if (item != null)
AppendItemExtras(sb, item);
return sb.ToString().TrimEnd();
}
}
protected Dictionary<int, SlotViewModel> SlotMap { get; } = new();
public ContainerViewModel(McClient handler, int windowId, ContainerType containerType)
{
Handler = handler;
WindowId = windowId;
ContainerType = containerType;
InitializeSlots();
RefreshFromContainer();
}
public void SetSlotDisplayParams(int maxWidth, int maxLines)
{
foreach (var kvp in SlotMap)
{
kvp.Value.NameMaxWidth = maxWidth;
kvp.Value.NameMaxLines = maxLines;
}
RefreshFromContainer();
}
protected virtual void InitializeSlots()
{
SlotMap.Clear();
int slotCount = ContainerType.SlotCount();
if (slotCount == 0) return;
int playerInvStart = slotCount - 36;
for (int i = 0; i < playerInvStart; i++)
{
var slot = new SlotViewModel(i);
ContainerSlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = playerInvStart; i < playerInvStart + 27; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = playerInvStart + 27; i < slotCount; i++)
{
int hotbarIdx = i - (playerInvStart + 27);
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
SlotMap[i] = slot;
}
}
public virtual void RefreshFromContainer()
{
Inventory.Container? container = Handler.GetInventory(WindowId);
if (container == null)
{
StatusText = Translations.tui_inventory_container_not_found;
return;
}
Title = string.Format(Translations.tui_inventory_title, WindowId, container.Title);
foreach (var kvp in SlotMap)
{
Item? item = container.Items.TryGetValue(kvp.Key, out var it) ? it : null;
kvp.Value.Update(item);
}
UpdateCursorItem(container);
int itemCount = 0;
foreach (var kvp in container.Items)
{
if (kvp.Key >= 0 && !kvp.Value.IsEmpty)
itemCount++;
}
StatusText = string.Format(Translations.tui_inventory_item_count, itemCount);
OnPropertyChanged(nameof(HoveredSlotDetailText));
}
protected void UpdateCursorItem(Inventory.Container _)
{
var playerInv = Handler.GetInventory(0);
if (playerInv != null && playerInv.Items.TryGetValue(-1, out var cursorItem) && !cursorItem.IsEmpty)
{
CursorItemInfo = FormatItemDetail(cursorItem);
HasCursorItem = true;
}
else
{
CursorItemInfo = "";
HasCursorItem = false;
}
}
protected static string FormatItemDetail(Item item)
{
var sb = new StringBuilder();
sb.AppendLine($"x{item.Count} {item.GetTypeString()}");
AppendItemExtras(sb, item);
if (sb.Length > 0 && sb[sb.Length - 1] == '\n')
sb.Remove(sb.Length - 1, 1);
return sb.ToString();
}
private static void AppendItemExtras(StringBuilder sb, Item item)
{
int damage = item.Damage;
if (damage != 0)
{
int maxDamage = item.Components?.OfType<MaxDamageComponent>().FirstOrDefault()?.MaxDamage ?? 0;
if (maxDamage > 0)
sb.AppendLine($"{Translations.tui_inventory_durability}: {maxDamage - damage}/{maxDamage}");
else
sb.AppendLine($"{Translations.cmd_inventory_damage}: {damage}");
}
try
{
var enchList = item.EnchantmentList;
if (enchList is not null)
{
bool isFirstEnchantment = true;
foreach (var ench in enchList)
{
string name = EnchantmentMapping.GetEnchantmentName(ench.Type);
string level = EnchantmentMapping.ConvertLevelToRomanNumbers(ench.Level);
if (isFirstEnchantment)
{
isFirstEnchantment = false;
sb.Append($"{name} {level}");
}
else
{
sb.Append($" | {name} {level}");
}
}
}
else if (item.NBT is not null &&
(item.NBT.TryGetValue("Enchantments", out object? enchantments) ||
item.NBT.TryGetValue("StoredEnchantments", out enchantments)))
{
bool isFirstEnchantment = true;
foreach (Dictionary<string, object> enchantment in (object[])enchantments)
{
short level = (short)enchantment["lvl"];
string id = ((string)enchantment["id"]).Replace(':', '.');
string name = Protocol.Message.ChatParser.TranslateString("enchantment." + id) ?? id;
string levelStr = Protocol.Message.ChatParser.TranslateString("enchantment.level." + level) ?? level.ToString();
if (isFirstEnchantment)
{
isFirstEnchantment = false;
sb.Append($"{name} {levelStr}");
}
else
{
sb.Append($" | {name} {levelStr}");
}
}
}
}
catch { }
}
public bool PerformAction(int slotId, WindowActionType action)
{
bool result = Handler.DoWindowAction(WindowId, slotId, action);
RefreshFromContainer();
return result;
}
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}
}

View file

@ -0,0 +1,112 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class CraftingView : ContainerViewBase
{
private readonly CraftingViewModel _craftVm;
public CraftingView(McClient handler, int windowId)
: base(new CraftingViewModel(handler, windowId))
{
_craftVm = (CraftingViewModel)_vm;
Initialize();
}
protected override int GetTotalSlotRows()
{
return 3 + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
var row = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
};
var gridPanel = new StackPanel { Spacing = 0 };
gridPanel.Children.Add(new TextBlock
{
Text = Translations.tui_crafting_grid,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
gridPanel.Children.Add(BuildSlotGrid(_craftVm.CraftingGridSlots, 3));
row.Children.Add(gridPanel);
row.Children.Add(new TextBlock
{
Text = " \u2192 ",
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
VerticalAlignment = VerticalAlignment.Center,
});
var outPanel = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
};
outPanel.Children.Add(new TextBlock
{
Text = Translations.tui_inventory_output,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
outPanel.Children.Add(CreateSlotCell(_craftVm.OutputSlot, 0, 0));
row.Children.Add(outPanel);
return row;
}
}
public class CraftingViewModel : ContainerViewModel
{
public ObservableCollection<SlotViewModel> CraftingGridSlots { get; } = new();
public SlotViewModel OutputSlot { get; private set; } = null!;
public CraftingViewModel(McClient handler, int windowId)
: base(handler, windowId, ContainerType.Crafting)
{
OutputSlot = SlotMap[0];
}
protected override void InitializeSlots()
{
SlotMap.Clear();
var output = new SlotViewModel(0);
SlotMap[0] = output;
for (int i = 1; i <= 9; i++)
{
var slot = new SlotViewModel(i);
CraftingGridSlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = 10; i <= 36; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = 37; i <= 45; i++)
{
int hotbarIdx = i - 37;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
SlotMap[i] = slot;
}
}
}
}

View file

@ -0,0 +1,198 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class EnchantingTableView : ContainerViewBase
{
private readonly EnchantingViewModel _enchantVm;
private readonly TextBlock[] _enchantNameLabels = new TextBlock[3];
private readonly TextBlock[] _enchantCostLabels = new TextBlock[3];
public EnchantingTableView(McClient handler, int windowId)
: base(new EnchantingViewModel(handler, windowId))
{
_enchantVm = (EnchantingViewModel)_vm;
Initialize();
}
private void RefreshEnchantOptions()
{
var container = _vm.Handler.GetInventory(_vm.WindowId);
if (container == null) return;
int protocolVersion = _vm.Handler.GetProtocolVersion();
for (int i = 0; i < 3; i++)
{
if (_enchantNameLabels[i] == null) continue;
short levelReq = container.Properties.TryGetValue(i, out var lr) ? lr : (short)0;
short enchantId = container.Properties.TryGetValue(i + 4, out var eid) ? eid : (short)-1;
short enchantLevel = container.Properties.TryGetValue(i + 7, out var el) ? el : (short)0;
if (levelReq > 0 && enchantId >= 0)
{
try
{
var enchant = EnchantmentMapping.GetEnchantmentById(protocolVersion, enchantId);
string name = EnchantmentMapping.GetEnchantmentName(enchant);
string roman = EnchantmentMapping.ConvertLevelToRomanNumbers(enchantLevel);
_enchantNameLabels[i].Text = $"{name} {roman}";
_enchantCostLabels[i].Text = $" ({levelReq})";
}
catch
{
_enchantNameLabels[i].Text = string.Format(Translations.tui_enchanting_option_slot, i + 1);
_enchantCostLabels[i].Text = levelReq > 0 ? $" ({levelReq})" : "";
}
}
else
{
_enchantNameLabels[i].Text = string.Format(Translations.tui_enchanting_option_slot, i + 1);
_enchantCostLabels[i].Text = "";
}
}
}
protected override void OnContainerDataChanged()
{
RefreshEnchantOptions();
}
protected override int GetTotalSlotRows()
{
return 3 + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Spacing = 0,
};
var slotsCol = new StackPanel
{
Spacing = 0,
VerticalAlignment = VerticalAlignment.Center,
};
slotsCol.Children.Add(new TextBlock
{
Text = Translations.tui_enchanting_item,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
slotsCol.Children.Add(CreateSlotCell(_enchantVm.ItemSlot, 0, 0));
slotsCol.Children.Add(new TextBlock
{
Text = Translations.tui_enchanting_lapis,
Foreground = new SolidColorBrush(Color.FromRgb(60, 80, 200)),
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
slotsCol.Children.Add(CreateSlotCell(_enchantVm.LapisSlot, 1, 0));
panel.Children.Add(slotsCol);
var optionsCol = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(2, 0, 0, 0),
};
optionsCol.Children.Add(new TextBlock
{
Text = Translations.tui_enchanting_options,
Foreground = Brushes.Magenta,
FontWeight = FontWeight.Bold,
});
int optionWidth = System.Math.Max(_slotW * 4, 30);
for (int i = 0; i < 3; i++)
{
var nameLabel = new TextBlock
{
Text = string.Format(Translations.tui_enchanting_option_slot, i + 1),
Foreground = new SolidColorBrush(Color.FromRgb(100, 200, 70)),
TextWrapping = TextWrapping.NoWrap,
};
_enchantNameLabels[i] = nameLabel;
var costLabel = new TextBlock
{
Foreground = new SolidColorBrush(Color.FromRgb(100, 200, 70)),
FontWeight = FontWeight.Bold,
VerticalAlignment = VerticalAlignment.Center,
};
_enchantCostLabels[i] = costLabel;
var content = new DockPanel();
DockPanel.SetDock(costLabel, Dock.Right);
content.Children.Add(costLabel);
content.Children.Add(nameLabel);
optionsCol.Children.Add(new Border
{
Background = new SolidColorBrush(Color.FromRgb(55, 50, 40)),
MinWidth = optionWidth,
MinHeight = _slotH,
Padding = new Thickness(1, 0),
Child = content,
});
}
RefreshEnchantOptions();
panel.Children.Add(optionsCol);
return panel;
}
}
public class EnchantingViewModel : ContainerViewModel
{
public SlotViewModel ItemSlot { get; private set; } = null!;
public SlotViewModel LapisSlot { get; private set; } = null!;
public EnchantingViewModel(McClient handler, int windowId)
: base(handler, windowId, ContainerType.Enchantment)
{
ItemSlot = SlotMap[0];
LapisSlot = SlotMap[1];
}
protected override void InitializeSlots()
{
SlotMap.Clear();
SlotMap[0] = new SlotViewModel(0);
SlotMap[1] = new SlotViewModel(1);
for (int i = 2; i <= 28; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = 29; i <= 37; i++)
{
int hotbarIdx = i - 29;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
SlotMap[i] = slot;
}
}
}
}

View file

@ -0,0 +1,133 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class FurnaceView : ContainerViewBase
{
private readonly FurnaceViewModel _furnaceVm;
public FurnaceView(McClient handler, int windowId, ContainerType type)
: base(new FurnaceViewModel(handler, windowId, type))
{
_furnaceVm = (FurnaceViewModel)_vm;
Initialize();
}
protected override int GetTotalSlotRows()
{
return 3 + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Spacing = 0,
};
var leftCol = new StackPanel
{
Spacing = 0,
VerticalAlignment = VerticalAlignment.Center,
};
leftCol.Children.Add(new TextBlock
{
Text = Translations.tui_furnace_input,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
leftCol.Children.Add(CreateSlotCell(_furnaceVm.InputSlot, 0, 0));
leftCol.Children.Add(new TextBlock
{
Text = "\u2592\u2592\u2592",
Foreground = new SolidColorBrush(Color.FromRgb(180, 100, 40)),
HorizontalAlignment = HorizontalAlignment.Center,
});
leftCol.Children.Add(new TextBlock
{
Text = Translations.tui_furnace_fuel,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
leftCol.Children.Add(CreateSlotCell(_furnaceVm.FuelSlot, 1, 0));
panel.Children.Add(leftCol);
panel.Children.Add(new TextBlock
{
Text = " \u2192 ",
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
VerticalAlignment = VerticalAlignment.Center,
});
var rightCol = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
};
rightCol.Children.Add(new TextBlock
{
Text = Translations.tui_furnace_output,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
rightCol.Children.Add(CreateSlotCell(_furnaceVm.OutputSlot, 0, 1));
panel.Children.Add(rightCol);
return panel;
}
}
public class FurnaceViewModel : ContainerViewModel
{
public SlotViewModel InputSlot { get; private set; } = null!;
public SlotViewModel FuelSlot { get; private set; } = null!;
public SlotViewModel OutputSlot { get; private set; } = null!;
public FurnaceViewModel(McClient handler, int windowId, ContainerType type)
: base(handler, windowId, type)
{
InputSlot = SlotMap[0];
FuelSlot = SlotMap[1];
OutputSlot = SlotMap[2];
}
protected override void InitializeSlots()
{
SlotMap.Clear();
SlotMap[0] = new SlotViewModel(0);
SlotMap[1] = new SlotViewModel(1);
SlotMap[2] = new SlotViewModel(2);
for (int i = 3; i <= 29; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = 30; i <= 38; i++)
{
int hotbarIdx = i - 30;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
SlotMap[i] = slot;
}
}
}
}

View file

@ -0,0 +1,31 @@
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class GridContainerView : ContainerViewBase
{
private readonly int _gridRows;
private readonly int _gridCols;
public GridContainerView(McClient handler, int windowId, ContainerType type, int rows, int cols)
: base(new ContainerViewModel(handler, windowId, type))
{
_gridRows = rows;
_gridCols = cols;
Initialize();
}
protected override int GetTotalSlotRows()
{
return _gridRows + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
return BuildSlotGrid(_vm.ContainerSlots, _gridCols);
}
}
}

View file

@ -0,0 +1,126 @@
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class GrindstoneView : ContainerViewBase
{
private readonly GrindstoneViewModel _grindVm;
public GrindstoneView(McClient handler, int windowId)
: base(new GrindstoneViewModel(handler, windowId))
{
_grindVm = (GrindstoneViewModel)_vm;
Initialize();
}
protected override int GetTotalSlotRows()
{
return 2 + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
var row = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
Spacing = 0,
};
var inputCol = new StackPanel
{
Spacing = 0,
VerticalAlignment = VerticalAlignment.Center,
};
inputCol.Children.Add(new TextBlock
{
Text = Translations.tui_grindstone_input1,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
inputCol.Children.Add(CreateSlotCell(_grindVm.Input1Slot, 0, 0));
inputCol.Children.Add(new TextBlock
{
Text = Translations.tui_grindstone_input2,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
inputCol.Children.Add(CreateSlotCell(_grindVm.Input2Slot, 1, 0));
row.Children.Add(inputCol);
row.Children.Add(new TextBlock
{
Text = "=>",
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
VerticalAlignment = VerticalAlignment.Center,
Padding = new Thickness(1, 0),
});
var outCol = new StackPanel
{
VerticalAlignment = VerticalAlignment.Center,
};
outCol.Children.Add(new TextBlock
{
Text = Translations.tui_inventory_output,
Foreground = BrEquipLbl,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
outCol.Children.Add(CreateSlotCell(_grindVm.OutputSlot, 0, 1));
row.Children.Add(outCol);
return row;
}
}
public class GrindstoneViewModel : ContainerViewModel
{
public SlotViewModel Input1Slot { get; private set; } = null!;
public SlotViewModel Input2Slot { get; private set; } = null!;
public SlotViewModel OutputSlot { get; private set; } = null!;
public GrindstoneViewModel(McClient handler, int windowId)
: base(handler, windowId, ContainerType.Grindstone)
{
Input1Slot = SlotMap[0];
Input2Slot = SlotMap[1];
OutputSlot = SlotMap[2];
}
protected override void InitializeSlots()
{
SlotMap.Clear();
SlotMap[0] = new SlotViewModel(0);
SlotMap[1] = new SlotViewModel(1);
SlotMap[2] = new SlotViewModel(2);
for (int i = 3; i <= 29; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
SlotMap[i] = slot;
}
for (int i = 30; i <= 38; i++)
{
int hotbarIdx = i - 30;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
SlotMap[i] = slot;
}
}
}
}

View file

@ -0,0 +1,30 @@
using Avalonia.Controls;
using Avalonia.Layout;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class HopperView : ContainerViewBase
{
public HopperView(McClient handler, int windowId)
: base(new ContainerViewModel(handler, windowId, ContainerType.Hopper))
{
Initialize();
}
protected override int GetTotalSlotRows()
{
return 1 + 3 + 1;
}
protected override Control BuildContainerSpecificArea()
{
var grid = BuildSlotGrid(_vm.ContainerSlots, 5);
return new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
Children = { grid },
};
}
}
}

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 Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Consolonia.Themes;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
@ -16,9 +17,15 @@ namespace MinecraftClient.Tui
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var handler = InventoryTuiHost.ActiveHandler!;
var windowId = InventoryTuiHost.ActiveWindowId;
var container = handler.GetInventory(windowId);
var containerType = container?.Type ?? ContainerType.PlayerInventory;
var view = ContainerViewBase.CreateView(containerType, handler, windowId);
desktop.MainWindow = new Window
{
Content = new InventoryMainView(),
Content = view,
Title = "MCC Inventory"
};
}

View file

@ -1,304 +1,39 @@
using System;
using System.Collections.ObjectModel;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class InventoryMainView : UserControl
public class PlayerInventoryView : ContainerViewBase
{
private static readonly IBrush BrSlotEmptyA = new SolidColorBrush(Color.FromRgb(40, 40, 40));
private static readonly IBrush BrSlotEmptyB = new SolidColorBrush(Color.FromRgb(55, 55, 55));
private static readonly IBrush BrSlotFillA = new SolidColorBrush(Color.FromRgb(60, 60, 75));
private static readonly IBrush BrSlotFillB = new SolidColorBrush(Color.FromRgb(75, 75, 90));
private static readonly IBrush BrSlotHover = new SolidColorBrush(Color.FromRgb(100, 100, 140));
private static readonly IBrush BrName = Brushes.White;
private static readonly IBrush BrCount = Brushes.Yellow;
private static readonly IBrush BrDim = new SolidColorBrush(Color.FromRgb(80, 80, 80));
private static readonly IBrush BrEquipLbl = Brushes.DarkCyan;
private static readonly IBrush BrInfoHighlight = new SolidColorBrush(Color.FromRgb(40, 40, 60));
private static readonly IBrush BrHeldItemBg = new SolidColorBrush(Color.FromRgb(60, 50, 80));
private static readonly IBrush BrHeldItemBorder = Brushes.Yellow;
private int _slotW;
private int _slotH;
private int _nameMaxLen;
private int _nameLines;
private readonly PlayerInventoryViewModel _playerVm;
private int _topGap;
private int _termW;
private readonly InventoryViewModel _vm;
private TextBlock _titleText = null!;
private Border _infoDetailBorder = null!;
private TextBlock _infoDetailText = null!;
private TextBlock _cursorItemText = null!;
private TextBlock _helpText = null!;
private TextBlock[] _hotbarIndicators = new TextBlock[9];
private int _currentHotbarSlot = -1;
private Border? _lastHoveredSlotBorder;
private Canvas _overlayCanvas = null!;
private Border _heldItemFloater = null!;
private TextBlock _heldItemFloaterName = null!;
private TextBlock _heldItemFloaterCount = null!;
private ScrollViewer _chatScrollViewer = null!;
private ObservableCollection<string>? _chatLines;
private int _lastTermW;
private int _lastTermH;
public InventoryMainView()
public PlayerInventoryView(McClient handler, int windowId)
: base(new PlayerInventoryViewModel(handler, windowId))
{
var handler = InventoryTuiHost.ActiveHandler
?? throw new InvalidOperationException("No active McClient");
int windowId = InventoryTuiHost.ActiveWindowId;
_vm = new InventoryViewModel(handler, windowId);
_currentHotbarSlot = handler.GetCurrentSlot();
_chatLines = TuiConsoleBackend.Instance?.GetView()?.GetRecentLogLines(50)
?? new ObservableCollection<string>();
RebuildUi();
_playerVm = (PlayerInventoryViewModel)_vm;
Initialize();
}
private void RebuildUi()
protected override int GetTotalSlotRows()
{
int termH;
try
{
_termW = System.Console.WindowWidth;
termH = System.Console.WindowHeight;
}
catch
{
_termW = 120;
termH = 40;
}
_lastTermW = _termW;
_lastTermH = termH;
int availW = _termW - 26;
_slotW = Math.Clamp(availW / 9, 8, 18);
_nameMaxLen = _slotW;
int topUsedW = _slotW * 4 + 8 + _slotW * 2 + 4 + _slotW;
_topGap = Math.Max(2, (_slotW * 9 - topUsedW) / 2);
_slotH = Math.Clamp((termH - 8) / 6, 2, 5);
_nameLines = _slotH;
_vm.SetSlotDisplayParams(_nameMaxLen, _nameLines);
_lastHoveredSlotBorder = null;
_titleText = new TextBlock
{
FontWeight = FontWeight.Bold,
Foreground = Brushes.Cyan,
HorizontalAlignment = HorizontalAlignment.Center,
};
_infoDetailText = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Foreground = Brushes.White,
};
_infoDetailBorder = new Border
{
Background = Brushes.Transparent,
Padding = new Thickness(0),
Child = _infoDetailText,
};
_cursorItemText = new TextBlock
{
Foreground = Brushes.Yellow,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap,
};
_helpText = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Color.FromRgb(140, 140, 140)),
Text = Translations.tui_inventory_controls_help,
};
_heldItemFloaterName = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
TextWrapping = TextWrapping.Wrap,
};
_heldItemFloaterCount = new TextBlock
{
Foreground = BrCount,
FontWeight = FontWeight.Bold,
};
_heldItemFloater = new Border
{
Background = BrHeldItemBg,
BorderBrush = BrHeldItemBorder,
BorderThickness = new Thickness(1),
Padding = new Thickness(1, 0),
IsVisible = false,
MaxWidth = 24,
Child = new StackPanel
{
Children = { _heldItemFloaterName, _heldItemFloaterCount },
},
};
_overlayCanvas = new Canvas { IsHitTestVisible = false };
_overlayCanvas.Children.Add(_heldItemFloater);
var chatLines = _chatLines!;
chatLines.CollectionChanged += (_, _) =>
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
var sv = _chatScrollViewer;
if (sv.Extent.Height > sv.Viewport.Height)
sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
}, Avalonia.Threading.DispatcherPriority.Background);
};
var chatItemsControl = new ItemsControl
{
ItemsSource = chatLines,
Focusable = false,
ItemTemplate = new FuncDataTemplate<string>((s, _) =>
new TextBlock
{
Text = s,
Foreground = Brushes.Gray,
Padding = new Thickness(0),
Margin = new Thickness(0),
TextWrapping = TextWrapping.Wrap,
}),
};
_chatScrollViewer = new ScrollViewer
{
Content = chatItemsControl,
Background = Brushes.Black,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Hidden,
Padding = new Thickness(0),
};
_hotbarIndicators = new TextBlock[9];
Content = BuildRootLayout();
UpdateTitle();
UpdateInfoPanel();
_chatScrollToBottom = true;
_chatScrollViewer.ScrollChanged += OnChatScrollChanged;
return 6;
}
private bool _chatScrollToBottom = true;
private void OnChatScrollChanged(object? sender, ScrollChangedEventArgs e)
protected override void RebuildUi()
{
if (!_chatScrollToBottom) return;
var sv = _chatScrollViewer;
if (sv.Extent.Height > sv.Viewport.Height)
{
sv.Offset = new Vector(0, sv.Extent.Height - sv.Viewport.Height);
_chatScrollToBottom = false;
}
int availW = 0;
try { availW = System.Console.WindowWidth - 26; } catch { availW = 94; }
int slotW = System.Math.Clamp(availW / 9, 8, 18);
int topUsedW = slotW * 4 + 8 + slotW * 2 + 4 + slotW;
_topGap = System.Math.Max(2, (slotW * 9 - topUsedW) / 2);
base.RebuildUi();
}
private Control BuildRootLayout()
{
// Layout (top-down):
// Title
// [InfoPanel(right)] [InventoryGrid(left)] <-- inventory area
// ChatScrollViewer (full width, fills remaining)
var inventoryArea = BuildMainArea();
DockPanel.SetDock(_titleText, Dock.Top);
DockPanel.SetDock(inventoryArea, Dock.Top);
var mainContent = new DockPanel
{
Children = { _titleText, inventoryArea, _chatScrollViewer }
};
return new Panel
{
Background = Brushes.Black,
Children = { mainContent, _overlayCanvas }
};
}
private Control BuildMainArea()
{
var infoPanel = BuildInfoPanel();
DockPanel.SetDock(infoPanel, Dock.Right);
return new DockPanel
{
Children = { infoPanel, BuildInventoryPanel() }
};
}
private Control BuildInfoPanel()
{
return new Border
{
BorderThickness = new Thickness(1),
BorderBrush = Brushes.Gray,
Padding = new Thickness(1),
Width = 24,
Child = new StackPanel
{
Children =
{
new TextBlock { Text = Translations.tui_inventory_item_info, FontWeight = FontWeight.Bold, Foreground = Brushes.Cyan },
_infoDetailBorder,
new TextBlock { Text = Translations.tui_inventory_held_item, FontWeight = FontWeight.Bold, Foreground = Brushes.Yellow, Margin = new Thickness(0, 1, 0, 0) },
_cursorItemText,
new TextBlock { Text = Translations.tui_inventory_controls, FontWeight = FontWeight.Bold, Foreground = Brushes.Green, Margin = new Thickness(0, 1, 0, 0) },
_helpText,
}
}
};
}
private Control BuildInventoryPanel()
{
var root = new StackPanel
{
Spacing = 0,
HorizontalAlignment = HorizontalAlignment.Center,
};
root.Children.Add(BuildTopSection());
root.Children.Add(new Border { Height = 1 });
root.Children.Add(BuildSlotGrid(_vm.MainInventorySlots, 9));
root.Children.Add(BuildHotbarSection());
return new Border
{
BorderThickness = new Thickness(1),
BorderBrush = Brushes.Gray,
Child = root,
};
}
private Control BuildTopSection()
protected override Control BuildContainerSpecificArea()
{
var row = new StackPanel
{
@ -318,7 +53,7 @@ namespace MinecraftClient.Tui
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
offPanel.Children.Add(CreateSlotCell(_vm.OffhandSlot, 0, 0));
offPanel.Children.Add(CreateSlotCell(_playerVm.OffhandSlot, 0, 0));
row.Children.Add(offPanel);
var equipGrid = new Grid
@ -332,7 +67,7 @@ namespace MinecraftClient.Tui
var lbl = MakeLabel(label);
Grid.SetRow(lbl, r); Grid.SetColumn(lbl, gc);
equipGrid.Children.Add(lbl);
var btn = CreateSlotCell(_vm.EquipmentSlots[eqIdx], r, gc / 2);
var btn = CreateSlotCell(_playerVm.EquipmentSlots[eqIdx], r, gc / 2);
Grid.SetRow(btn, r); Grid.SetColumn(btn, gc + 1);
equipGrid.Children.Add(btn);
}
@ -354,7 +89,7 @@ namespace MinecraftClient.Tui
for (int ci = 0; ci < 4; ci++)
{
int cr = ci / 2, cc = ci % 2;
var cs = CreateSlotCell(_vm.CraftingInputSlots[ci], cr, cc);
var cs = CreateSlotCell(_playerVm.CraftingInputSlots[ci], cr, cc);
Grid.SetRow(cs, cr);
Grid.SetColumn(cs, cc);
craftGrid.Children.Add(cs);
@ -382,7 +117,7 @@ namespace MinecraftClient.Tui
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
});
craftOutPanel.Children.Add(CreateSlotCell(_vm.CraftingOutputSlot, 0, 1));
craftOutPanel.Children.Add(CreateSlotCell(_playerVm.CraftingOutputSlot, 0, 1));
Grid.SetRow(craftOutPanel, 0); Grid.SetColumn(craftOutPanel, 3);
Grid.SetRowSpan(craftOutPanel, 2);
craftGrid.Children.Add(craftOutPanel);
@ -390,363 +125,5 @@ namespace MinecraftClient.Tui
row.Children.Add(craftGrid);
return row;
}
private Control BuildHotbarSection()
{
var panel = new StackPanel { Spacing = 0 };
var numberRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Center,
};
for (int i = 0; i < 9; i++)
{
bool active = i == _currentHotbarSlot;
string label = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
var tb = new TextBlock
{
Text = label,
Width = _slotW,
TextAlignment = TextAlignment.Center,
Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan,
FontWeight = FontWeight.Bold,
};
_hotbarIndicators[i] = tb;
numberRow.Children.Add(tb);
}
panel.Children.Add(numberRow);
panel.Children.Add(BuildSlotGrid(_vm.HotbarSlots, 9));
return panel;
}
private TextBlock MakeLabel(string text)
{
return new TextBlock
{
Text = text,
Foreground = BrEquipLbl,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(1, 0, 0, 0),
FontWeight = FontWeight.Bold,
};
}
private Control BuildSlotGrid(ObservableCollection<SlotViewModel> slots, int columns)
{
var grid = new Grid();
int rows = (slots.Count + columns - 1) / columns;
for (int r = 0; r < rows; r++)
grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
for (int c = 0; c < columns; c++)
grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
for (int i = 0; i < slots.Count; i++)
{
int row = i / columns;
int col = i % columns;
var cell = CreateSlotCell(slots[i], row, col);
Grid.SetRow(cell, row);
Grid.SetColumn(cell, col);
grid.Children.Add(cell);
}
return grid;
}
private static IBrush GetSlotBg(bool isEmpty, int row, int col)
{
bool isA = (row + col) % 2 == 0;
return isEmpty
? (isA ? BrSlotEmptyA : BrSlotEmptyB)
: (isA ? BrSlotFillA : BrSlotFillB);
}
private Border CreateSlotCell(SlotViewModel slot, int row = 0, int col = 0)
{
var nameTb = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Padding = new Thickness(0),
Margin = new Thickness(0),
VerticalAlignment = VerticalAlignment.Top,
};
var countTb = new TextBlock
{
Foreground = BrCount,
FontWeight = FontWeight.Bold,
Padding = new Thickness(0),
Margin = new Thickness(0),
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Bottom,
};
ApplySlotVisual(slot, nameTb, countTb);
int r = row, c = col;
var border = new Border
{
Width = _slotW,
Height = _slotH,
Background = GetSlotBg(slot.IsEmpty, r, c),
Child = new Panel
{
Children = { nameTb, countTb },
},
Tag = (slot, r, c),
};
border.PointerPressed += OnSlotPointerPressed;
border.PointerEntered += OnSlotPointerEnter;
border.PointerExited += OnSlotPointerExit;
border.PointerMoved += OnSlotPointerMoved;
slot.PropertyChanged += (_, _) =>
{
ApplySlotVisual(slot, nameTb, countTb);
border.Background = GetSlotBg(slot.IsEmpty, r, c);
};
return border;
}
private void ApplySlotVisual(SlotViewModel slot, TextBlock nameTb, TextBlock countTb)
{
if (slot.IsEmpty)
{
nameTb.Text = "";
nameTb.Foreground = BrDim;
countTb.Text = "";
}
else
{
nameTb.Text = slot.ItemDisplayText;
nameTb.Foreground = BrName;
countTb.Text = slot.CountDisplay;
}
}
private void OnSlotPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Border border || border.Tag is not (SlotViewModel slot, int, int))
return;
SetHover(border, slot);
var point = e.GetCurrentPoint(border);
bool isShift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
WindowActionType action;
if (point.Properties.IsRightButtonPressed)
action = isShift ? WindowActionType.ShiftRightClick : WindowActionType.RightClick;
else
action = isShift ? WindowActionType.ShiftClick : WindowActionType.LeftClick;
_vm.PerformAction(slot.SlotId, action);
UpdateInfoPanel();
UpdateHeldItemFloater(e);
e.Handled = true;
}
private void OnSlotPointerEnter(object? sender, PointerEventArgs e)
{
if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
{
SetHover(b, slot);
UpdateHeldItemFloater(e);
}
}
private void OnSlotPointerMoved(object? sender, PointerEventArgs e)
{
if (sender is Border b && b.Tag is (SlotViewModel slot, int, int))
{
SetHover(b, slot);
UpdateHeldItemFloater(e);
}
}
private void OnSlotPointerExit(object? sender, PointerEventArgs e)
{
if (sender is Border b && b.Tag is (SlotViewModel slot, int row, int col))
b.Background = GetSlotBg(slot.IsEmpty, row, col);
}
private void SetHover(Border border, SlotViewModel slot)
{
if (_lastHoveredSlotBorder != null && _lastHoveredSlotBorder != border)
{
if (_lastHoveredSlotBorder.Tag is (SlotViewModel oldSlot, int or, int oc))
_lastHoveredSlotBorder.Background = GetSlotBg(oldSlot.IsEmpty, or, oc);
}
_lastHoveredSlotBorder = border;
border.Background = BrSlotHover;
_vm.HoveredSlot = slot;
UpdateInfoPanel();
}
private void UpdateHeldItemFloater(PointerEventArgs e)
{
if (!_vm.HasCursorItem)
{
_heldItemFloater.IsVisible = false;
return;
}
_heldItemFloaterName.Text = _vm.CursorItemInfo;
_heldItemFloaterCount.Text = "";
try
{
var pos = e.GetPosition(_overlayCanvas);
double left = pos.X + 2;
double remainingW = _termW - left - 2;
int maxW = Math.Max(8, (int)remainingW);
_heldItemFloater.MaxWidth = maxW;
Canvas.SetLeft(_heldItemFloater, left);
Canvas.SetTop(_heldItemFloater, pos.Y);
}
catch
{
_heldItemFloater.MaxWidth = 24;
Canvas.SetLeft(_heldItemFloater, 0);
Canvas.SetTop(_heldItemFloater, 0);
}
_heldItemFloater.IsVisible = true;
}
private void UpdateInfoPanel()
{
_infoDetailText.Text = _vm.HoveredSlotDetailText;
bool hasHoveredItem = _vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty;
_infoDetailBorder.Background = hasHoveredItem ? BrInfoHighlight : Brushes.Transparent;
if (_vm.HasCursorItem)
{
_cursorItemText.Text = _vm.CursorItemInfo;
_cursorItemText.Foreground = Brushes.Yellow;
}
else
{
_cursorItemText.Text = Translations.tui_inventory_cursor_empty;
_cursorItemText.Foreground = BrDim;
_heldItemFloater.IsVisible = false;
}
}
private void UpdateTitle()
{
_titleText.Text = _vm.Title;
}
private void CloseInventory()
{
if (ConsoleIO.Backend is TuiConsoleBackend tuiBackend)
tuiBackend.GetView()?.HideOverlay();
else
(Application.Current?.ApplicationLifetime as IControlledApplicationLifetime)?.Shutdown();
}
protected override void OnKeyDown(KeyEventArgs e)
{
base.OnKeyDown(e);
switch (e.Key)
{
case Key.Escape:
case Key.E:
CloseInventory();
e.Handled = true;
break;
case Key.C:
if ((e.KeyModifiers & KeyModifiers.Shift) != 0 &&
_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
{
_vm.PerformAction(_vm.HoveredSlot.SlotId, WindowActionType.ShiftClick);
UpdateInfoPanel();
}
e.Handled = true;
break;
case Key.Q:
if (_vm.HoveredSlot != null && !_vm.HoveredSlot.IsEmpty)
{
var action = (e.KeyModifiers & KeyModifiers.Control) != 0
? WindowActionType.DropItemStack
: WindowActionType.DropItem;
_vm.PerformAction(_vm.HoveredSlot.SlotId, action);
UpdateInfoPanel();
}
e.Handled = true;
break;
case Key.R:
_vm.RefreshFromContainer();
_currentHotbarSlot = _vm.Handler.GetCurrentSlot();
UpdateHotbarIndicators();
UpdateInfoPanel();
e.Handled = true;
break;
}
}
private void UpdateHotbarIndicators()
{
for (int i = 0; i < 9; i++)
{
bool active = i == _currentHotbarSlot;
_hotbarIndicators[i].Text = active ? $"{i + 1} \u25bc" : $" {i + 1} ";
_hotbarIndicators[i].Foreground = active ? Brushes.LightGreen : Brushes.DarkCyan;
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
Focusable = true;
Focus();
AddHandler(KeyDownEvent, OnTunnelKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel);
SizeChanged += OnViewSizeChanged;
}
private void OnTunnelKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
CloseInventory();
e.Handled = true;
}
}
private void OnViewSizeChanged(object? sender, SizeChangedEventArgs e)
{
int newW, newH;
try
{
newW = System.Console.WindowWidth;
newH = System.Console.WindowHeight;
}
catch { return; }
if (newW == _lastTermW && newH == _lastTermH) return;
_vm.RefreshFromContainer();
_currentHotbarSlot = _vm.Handler.GetCurrentSlot();
RebuildUi();
Focus();
}
protected override void OnGotFocus(GotFocusEventArgs e)
{
base.OnGotFocus(e);
Focusable = true;
}
}
}

View file

@ -22,6 +22,30 @@ namespace MinecraftClient.Tui
public static bool IsRunning => _isRunning;
/// <summary>
/// Called by McClient.OnInventoryClose when the server closes a container.
/// If the closed window matches the active TUI window, auto-close the TUI.
/// </summary>
public static void NotifyInventoryClosed(int windowId)
{
if (!_isRunning || windowId != ActiveWindowId)
return;
if (ConsoleIO.Backend is TuiConsoleBackend)
{
Dispatcher.UIThread.Post(() =>
{
var view = TuiConsoleBackend.Instance?.GetView();
view?.HideOverlay();
});
}
else
{
(Avalonia.Application.Current?.ApplicationLifetime
as Avalonia.Controls.ApplicationLifetimes.IControlledApplicationLifetime)?.Shutdown();
}
}
/// <summary>
/// Whether the TUI can be launched (classic mode has a one-shot limit).
/// </summary>
@ -89,7 +113,10 @@ namespace MinecraftClient.Tui
var view = TuiConsoleBackend.Instance?.GetView();
if (view != null)
{
var content = new InventoryMainView();
var container = ActiveHandler!.GetInventory(ActiveWindowId);
var content = ContainerViewBase.CreateView(
container?.Type ?? ContainerType.PlayerInventory,
ActiveHandler, ActiveWindowId);
view.ShowOverlay(content, () =>
{
ActiveHandler = null;

View file

@ -1,152 +1,48 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text;
using MinecraftClient.Inventory;
namespace MinecraftClient.Tui
{
public class InventoryViewModel : INotifyPropertyChanged
public class PlayerInventoryViewModel : ContainerViewModel
{
private SlotViewModel? _hoveredSlot;
private string _title = "";
private string _statusText = "";
private string _cursorItemInfo = "";
private bool _hasCursorItem;
public McClient Handler { get; }
public int WindowId { get; }
public ObservableCollection<SlotViewModel> EquipmentSlots { get; } = new();
public ObservableCollection<SlotViewModel> CraftingInputSlots { get; } = new();
public SlotViewModel CraftingOutputSlot { get; }
public ObservableCollection<SlotViewModel> MainInventorySlots { get; } = new();
public ObservableCollection<SlotViewModel> HotbarSlots { get; } = new();
public SlotViewModel OffhandSlot { get; }
public string Title
public PlayerInventoryViewModel(McClient handler, int windowId)
: base(handler, windowId, ContainerType.PlayerInventory)
{
get => _title;
set { _title = value; OnPropertyChanged(); }
CraftingOutputSlot = SlotMap[0];
OffhandSlot = SlotMap[45];
}
public string StatusText
protected override void InitializeSlots()
{
get => _statusText;
set { _statusText = value; OnPropertyChanged(); }
}
SlotMap.Clear();
public string CursorItemInfo
{
get => _cursorItemInfo;
set { _cursorItemInfo = value; OnPropertyChanged(); }
}
public bool HasCursorItem
{
get => _hasCursorItem;
set { _hasCursorItem = value; OnPropertyChanged(); }
}
public SlotViewModel? HoveredSlot
{
get => _hoveredSlot;
set
{
if (_hoveredSlot != null)
_hoveredSlot.IsHovered = false;
_hoveredSlot = value;
if (_hoveredSlot != null)
_hoveredSlot.IsHovered = true;
OnPropertyChanged();
OnPropertyChanged(nameof(HoveredSlotDetailText));
}
}
/// <summary>
/// Multi-line detail text for the hovered slot.
/// </summary>
public string HoveredSlotDetailText
{
get
{
if (_hoveredSlot == null)
return Translations.tui_inventory_hover_hint;
if (_hoveredSlot.IsEmpty)
return $"Slot #{_hoveredSlot.SlotId}\n{Translations.tui_inventory_slot_empty}";
var sb = new StringBuilder();
sb.AppendLine(_hoveredSlot.ItemTypeName);
sb.AppendLine(string.Format(Translations.tui_inventory_slot_detail, _hoveredSlot.SlotId, _hoveredSlot.ItemCount));
string fullInfo = _hoveredSlot.FullInfo;
if (!string.IsNullOrEmpty(fullInfo))
{
string[] parts = fullInfo.Split(" | ");
for (int i = 1; i < parts.Length; i++)
sb.AppendLine(parts[i].Trim());
}
return sb.ToString().TrimEnd();
}
}
private Dictionary<int, SlotViewModel> _slotMap = new();
private int _nameMaxLen = 9;
private int _nameMaxLines = 1;
public InventoryViewModel(McClient handler, int windowId)
{
Handler = handler;
WindowId = windowId;
CraftingOutputSlot = new SlotViewModel(0);
OffhandSlot = new SlotViewModel(45);
InitializeSlots();
RefreshFromContainer();
}
public void SetSlotDisplayParams(int maxWidth, int maxLines)
{
_nameMaxLen = maxWidth;
_nameMaxLines = maxLines;
foreach (var kvp in _slotMap)
{
kvp.Value.NameMaxWidth = maxWidth;
kvp.Value.NameMaxLines = maxLines;
}
RefreshFromContainer();
}
private void InitializeSlots()
{
_slotMap.Clear();
_slotMap[0] = CraftingOutputSlot;
var craftOut = new SlotViewModel(0);
SlotMap[0] = craftOut;
for (int i = 1; i <= 4; i++)
{
var slot = new SlotViewModel(i);
CraftingInputSlots.Add(slot);
_slotMap[i] = slot;
SlotMap[i] = slot;
}
for (int i = 5; i <= 8; i++)
{
var slot = new SlotViewModel(i);
EquipmentSlots.Add(slot);
_slotMap[i] = slot;
SlotMap[i] = slot;
}
for (int i = 9; i <= 35; i++)
{
var slot = new SlotViewModel(i);
MainInventorySlots.Add(slot);
_slotMap[i] = slot;
SlotMap[i] = slot;
}
for (int i = 36; i <= 44; i++)
@ -154,67 +50,11 @@ namespace MinecraftClient.Tui
int hotbarIdx = i - 36;
var slot = new SlotViewModel(i, isHotbar: true, hotbarIndex: hotbarIdx);
HotbarSlots.Add(slot);
_slotMap[i] = slot;
SlotMap[i] = slot;
}
_slotMap[45] = OffhandSlot;
}
public void RefreshFromContainer()
{
Inventory.Container? container = Handler.GetInventory(WindowId);
if (container == null)
{
StatusText = Translations.tui_inventory_container_not_found;
return;
}
Title = string.Format(Translations.tui_inventory_title, WindowId, container.Title);
foreach (var kvp in _slotMap)
{
Item? item = container.Items.TryGetValue(kvp.Key, out var it) ? it : null;
kvp.Value.Update(item);
}
UpdateCursorItem(container);
int itemCount = 0;
foreach (var kvp in container.Items)
{
if (kvp.Key >= 0 && !kvp.Value.IsEmpty)
itemCount++;
}
StatusText = string.Format(Translations.tui_inventory_item_count, itemCount);
OnPropertyChanged(nameof(HoveredSlotDetailText));
}
private void UpdateCursorItem(Inventory.Container container)
{
if (container.Items.TryGetValue(-1, out var cursorItem) && !cursorItem.IsEmpty)
{
CursorItemInfo = $"x{cursorItem.Count} {cursorItem.GetTypeString()}";
HasCursorItem = true;
}
else
{
CursorItemInfo = "";
HasCursorItem = false;
}
}
public bool PerformAction(int slotId, WindowActionType action)
{
bool result = Handler.DoWindowAction(WindowId, slotId, action);
RefreshFromContainer();
return result;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
var offhand = new SlotViewModel(45);
SlotMap[45] = offhand;
}
}
}

View file

@ -1,6 +1,8 @@
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;
@ -9,14 +11,26 @@ using Avalonia.Input;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using MinecraftClient.Inventory;
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;
@ -39,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>();
@ -51,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;
@ -68,6 +90,7 @@ namespace MinecraftClient.Tui
{
ItemsSource = _logControls,
Focusable = false,
ItemsPanel = new FuncTemplate<Panel?>(() => new VirtualizingStackPanel()),
};
_logScrollViewer = new ScrollViewer
@ -148,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,
@ -162,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();
}
@ -272,7 +330,7 @@ namespace MinecraftClient.Tui
private void OnCommandKeyDown(object? sender, KeyEventArgs e)
{
if (_tabCycling && e.Key is not (Key.Tab or Key.Up or Key.Down or Key.Escape))
if (_tabCycling && e.Key is not Key.Tab)
_tabCycling = false;
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
@ -494,10 +552,32 @@ namespace MinecraftClient.Tui
}
string historyText = _commandHistory[_historyIndex];
_commandInput.Text = historyText;
_commandInput.CaretIndex = historyText.Length;
Dispatcher.UIThread.Post(() => _commandInput.CaretIndex = historyText.Length,
DispatcherPriority.Input);
SetCommandText(historyText);
}
private void SetCommandText(string text)
{
_commandInput.TextChanged -= OnCommandTextChanged;
try
{
_commandInput.Text = text;
_commandInput.CaretIndex = text.Length;
}
finally
{
_commandInput.TextChanged += OnCommandTextChanged;
}
Dispatcher.UIThread.Post(() =>
{
var endKeyEvent = new KeyEventArgs
{
RoutedEvent = KeyDownEvent,
Key = Key.End,
Source = _commandInput,
};
_commandInput.RaiseEvent(endKeyEvent);
}, DispatcherPriority.Input);
}
#endregion
@ -855,6 +935,45 @@ namespace MinecraftClient.Tui
Foreground = new SolidColorBrush(Color.FromRgb(220, 190, 100)),
});
// Add effects display
var effects = client.GetPlayerEffects().Values
.Where(effectData => !effectData.IsExpired)
.OrderBy(effectData => effectData.Effect)
.ToArray();
if (effects.Length > 0)
{
bool showEffectNamesInTui = Settings.Config.Main.Advanced.ShowEffectNamesInTUI;
_statusBar.Inlines.Add(new Avalonia.Controls.Documents.Run(" | ")
{
Foreground = Brushes.Gray,
});
bool first = true;
foreach (var effectData in effects)
{
if (!first)
{
_statusBar.Inlines.Add(new Avalonia.Controls.Documents.Run(", ")
{
Foreground = Brushes.Gray,
});
}
first = false;
var color = GetEffectIconAndColor(effectData.Effect).Color;
var displayText = showEffectNamesInTui
? effectData.GetDisplayName()
: GetCompactEffectLabel(effectData);
displayText = $"{displayText} ({effectData.GetRemainingDurationText()})";
_statusBar.Inlines.Add(new Avalonia.Controls.Documents.Run(displayText)
{
Foreground = color,
});
}
}
_statusBar.IsVisible = true;
}
@ -873,6 +992,152 @@ namespace MinecraftClient.Tui
return sb.ToString();
}
private static string GetCompactEffectLabel(EffectData effectData)
{
var icon = GetEffectIconAndColor(effectData.Effect).Icon;
return effectData.Amplifier > 0
? $"{icon}{effectData.Amplifier + 1}"
: icon;
}
private static (string Icon, IBrush Color) GetEffectIconAndColor(Effects effect)
{
return effect switch
{
Effects.Speed => ("⚡", new SolidColorBrush(Color.FromRgb(135, 206, 235))),
Effects.Slowness => ("🐢", new SolidColorBrush(Color.FromRgb(139, 139, 139))),
Effects.Haste => ("⛏", new SolidColorBrush(Color.FromRgb(255, 215, 0))),
Effects.MiningFatigue => ("🔨", new SolidColorBrush(Color.FromRgb(64, 64, 64))),
Effects.Strength => ("⚔", new SolidColorBrush(Color.FromRgb(255, 99, 71))),
Effects.InstantHealth => ("❤", new SolidColorBrush(Color.FromRgb(255, 182, 193))),
Effects.InstantDamage => ("💀", new SolidColorBrush(Color.FromRgb(139, 0, 0))),
Effects.JumpBoost => ("🦘", new SolidColorBrush(Color.FromRgb(50, 205, 50))),
Effects.Nausea => ("💫", new SolidColorBrush(Color.FromRgb(85, 107, 47))),
Effects.Regeneration => ("✨", new SolidColorBrush(Color.FromRgb(255, 105, 180))),
Effects.Resistance => ("🛡", new SolidColorBrush(Color.FromRgb(112, 128, 144))),
Effects.FireResistance => ("🔥", new SolidColorBrush(Color.FromRgb(255, 140, 0))),
Effects.WaterBreathing => ("🐟", new SolidColorBrush(Color.FromRgb(0, 191, 255))),
Effects.Invisibility => ("👻", new SolidColorBrush(Color.FromRgb(200, 200, 200))),
Effects.Blindness => ("🕶", new SolidColorBrush(Color.FromRgb(50, 50, 50))),
Effects.NightVision => ("👁", new SolidColorBrush(Color.FromRgb(0, 255, 127))),
Effects.Hunger => ("🍔", new SolidColorBrush(Color.FromRgb(139, 69, 19))),
Effects.Weakness => ("💪", new SolidColorBrush(Color.FromRgb(128, 128, 128))),
Effects.Poison => ("☠", new SolidColorBrush(Color.FromRgb(75, 0, 130))),
Effects.Wither => ("🥀", new SolidColorBrush(Color.FromRgb(0, 0, 0))),
Effects.HealthBoost => ("💖", new SolidColorBrush(Color.FromRgb(255, 20, 147))),
Effects.Absorption => ("💛", new SolidColorBrush(Color.FromRgb(255, 215, 0))),
Effects.Saturation => ("🍖", new SolidColorBrush(Color.FromRgb(255, 165, 0))),
Effects.Glowing => ("💡", new SolidColorBrush(Color.FromRgb(255, 255, 150))),
Effects.Levitation => ("🎈", new SolidColorBrush(Color.FromRgb(147, 112, 219))),
Effects.Luck => ("🍀", new SolidColorBrush(Color.FromRgb(50, 205, 50))),
Effects.BadLuck => ("🐈‍⬛", new SolidColorBrush(Color.FromRgb(128, 0, 0))),
Effects.SlowFalling => ("🪶", new SolidColorBrush(Color.FromRgb(255, 182, 193))),
Effects.ConduitPower => ("🐡", new SolidColorBrush(Color.FromRgb(0, 255, 255))),
Effects.DolphinsGrace => ("🐬", new SolidColorBrush(Color.FromRgb(135, 206, 235))),
Effects.BadOmen => ("🏴", new SolidColorBrush(Color.FromRgb(0, 100, 0))),
Effects.HerooftheVillage => ("🎉", new SolidColorBrush(Color.FromRgb(255, 215, 0))),
_ => ("✦", new SolidColorBrush(Color.FromRgb(200, 200, 200))),
};
}
#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
@ -928,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

@ -47,9 +47,13 @@ namespace MinecraftClient.Tui
return tb;
}
tb.Background = Brushes.Black;
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++)
@ -57,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]);
@ -66,6 +70,8 @@ namespace MinecraftClient.Tui
currentColor = brush;
bold = false;
italic = false;
underline = false;
strikethrough = false;
}
else
{
@ -73,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;
}
}
@ -87,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)
{
@ -98,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

@ -25,14 +25,18 @@ namespace MinecraftClient.Tui
internal static TuiConsoleBackend? Instance { get; private set; }
private Program.StartupState? _pendingStartupState;
private readonly ManualResetEventSlim _viewReady = new(false);
/// <summary>
/// Initializes the Avalonia app and starts the main UI loop.
/// This blocks the calling thread until the TUI exits.
/// Before blocking, it starts MCC's remaining initialization on a background thread.
/// </summary>
public void RunTuiMainLoop(string[] args)
internal void RunTuiMainLoop(string[] args, Program.StartupState startupState)
{
Instance = this;
_pendingStartupState = startupState;
AppDomain.CurrentDomain.ProcessExit += (_, _) => RestoreTerminalState();
@ -46,7 +50,7 @@ namespace MinecraftClient.Tui
new Thread(() =>
{
Thread.Sleep(500);
_viewReady.Wait();
ContinueMccStartup(args);
})
{ Name = "MCC-Main", IsBackground = true }.Start();
@ -113,7 +117,15 @@ namespace MinecraftClient.Tui
{
try
{
Program.ContinueAfterTuiInit(args);
var instance = Instance;
if (instance?._pendingStartupState is { } state)
{
instance._pendingStartupState = null;
if (!Program.ProcessStartupState(state))
return;
}
Program.RunStartupSequence(args);
}
catch (Exception ex)
{
@ -124,6 +136,7 @@ namespace MinecraftClient.Tui
internal void SetView(MainTuiView view)
{
_view = view;
_viewReady.Set();
}
internal MainTuiView? GetView() => _view;
@ -256,7 +269,7 @@ namespace MinecraftClient.Tui
new Thread(() =>
{
Thread.Sleep(500);
Thread.Sleep(1000);
Environment.Exit(0);
}) { Name = "TUI-Exit-Guard", IsBackground = true }.Start();
}

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

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

@ -15,7 +15,7 @@ redirectFrom:
**Minecraft Console Client** has a number of default built in Chat Bots (Scripts/Plugins) which allow for various types of automation.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Settings refer to settings in the [configuration file](configuration.md)**
@ -80,7 +80,7 @@ redirectFrom:
#### `Beep_Enabled`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This might not work depending on your system or a console (terminal emulator).**
@ -243,7 +243,7 @@ redirectFrom:
#### `Use_Terrain_Handling`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to enable [Terrain Handling](configuration.md#terrainandmovements) in the settings and it's recommended to put the bot into an enclosure not to wander off. (Recommended size 5x5x5)**
@ -273,7 +273,7 @@ redirectFrom:
#### `Walk_Retries`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This happens on each trigger of the task, so it does not permanently switch to alternative method.**
@ -289,7 +289,7 @@ redirectFrom:
## Auto Attack
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [inventoryhandling](configuration.md#inventoryhandling) and [entityhandling](configuration.md#entityhandling) enabled in order for this bot to work.**
@ -445,7 +445,7 @@ redirectFrom:
## Auto Craft
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [inventoryhandling](configuration.md#inventoryhandling) enabled in order for basic crafting in the inventory to work, in addition if you want to use a crafting table, you need to enable [terrainandmovements](configuration.md#terrainandmovements) in order for bot to be able to reach the crafting table.**
@ -530,7 +530,7 @@ redirectFrom:
### Defining a recipe
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you're using `table` you need to set the `CraftingTable` setting.**
@ -630,13 +630,13 @@ redirectFrom:
Automatically digs block on specified locations.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [inventoryhandling](configuration.md#inventoryhandling) and [terrainandmovements](configuration.md#terrainandmovements) enabled in order for this bot to work.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Since MCC does not yet support accurate calculation of the collision volume of blocks, all blocks are considered as complete cubes when obtaining the position of the lookahead.**
@ -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:**
@ -806,7 +846,7 @@ redirectFrom:
Automatically drop items you don't need from the inventory.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [inventoryhandling](configuration.md#inventoryhandling) enabled in order for this bot to work**
@ -859,7 +899,7 @@ redirectFrom:
#### `Items`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**All item types can be found [here](https://mccteam.github.io/r/item/#L12).**
@ -885,7 +925,7 @@ redirectFrom:
Automatically eat food when your Hunger value is low.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [inventoryhandling](configuration.md#inventoryhandling) enabled in order for this bot to work**
@ -927,20 +967,21 @@ redirectFrom:
- **Description:**
Automatically catch fish using a fishing rod.
Bite detection combines bobber movement, bobber velocity, and splash sounds.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [entityhandling](configuration.md#entityhandling) enabled in order for this bot to work.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**To use the automatic rod switching and durability check feature, you need to enable [inventoryhandling](configuration.md#inventoryhandling).**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Note: To adjust the position or angle after catching a fish, you need to enable [terrainandmovements](configuration.md#terrainandmovements).**
@ -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:**
@ -1227,7 +1328,7 @@ redirectFrom:
#### `Retries`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This might get you banned by the server owners.**
@ -1304,7 +1405,7 @@ redirectFrom:
#### `Matches_File`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This file is not created by default, we recommend making a clone of the [`sample-matches.ini`](https://github.com/MCCTeam/Minecraft-Console-Client/blob/master/MinecraftClient/config/sample-matches.ini) and changing it according to your needs.**
@ -1330,7 +1431,7 @@ redirectFrom:
#### `Match_Colors`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This feature uses the `§` symbol for color matching**
@ -1847,7 +1948,7 @@ redirectFrom:
## Farmer
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Terrain And Movements](configuration.md#terrainandmovements) and [Inventory Handling](configuration.md#inventoryhandling) enabled in order for this bot to work.**
@ -1965,13 +2066,13 @@ redirectFrom:
This bot enables you to make a bot follow a specific player.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**The bot can be slow at times, you need to walk with a normal speed and to sometimes stop for it to be able to keep up with you, it's similar to making animals follow you when you're holding food in your hand. This is due to a slow pathfinding algorithm, we're working on getting a better one. You can tweak the update limit and find what works best for you. (NOTE: Do not but a very low one, because you might achieve the opposite, this might clog the thread for terrain handling) and thus slow the bot even more.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [terrainandmovements](configuration.md#terrainandmovements) and [entityhandling](configuration.md#entityhandling) enabled in order for this bot to work.**
@ -2030,7 +2131,7 @@ redirectFrom:
Also set `enabled` to `true`, then, add your username in the `botowners` INI setting, and finally, connect to the server and use `/tell <bot username> start` to start the game.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If the bot does not respond to bot owners, see the [Detecting chat messages](https://github.com/MCCTeam/Minecraft-Console-Client/tree/master/MinecraftClient/config#detecting-chat-messages) section.**
@ -2065,7 +2166,7 @@ redirectFrom:
#### `FileWords_EN`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This settings file is for English and is not created by the default**
@ -2081,7 +2182,7 @@ redirectFrom:
#### `FileWords_FR`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This settings file is for French and is not created by the default**
@ -2339,9 +2440,9 @@ redirectFrom:
- **Default:** `false`
#### `Rasize_Rendered_Image`
#### `Resize_Rendered_Image`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**The bigger the size, the less is the quality.**
@ -2369,7 +2470,7 @@ redirectFrom:
#### `Resize_To`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Might be a bit slow on less powerful systems when rendering a lot of maps. Lower down the resolution if you have any performance issues. If your system is not that powerful and can't handle it, use external tools for upscaling and resizing.**
@ -2397,7 +2498,7 @@ redirectFrom:
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Sometimes when the client connects, the [Discord Bridge](#discord-bridge) will be loaded a tiny bit after. Rendered map images are queued up and sent in order as soon as the [Discord Bridge](#discord-bridge) is ready and connected.**
@ -2527,7 +2628,7 @@ redirectFrom:
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Please note that due to technical limitations, the client player (you) will not be shown in the replay file**

View file

@ -11,10 +11,6 @@ redirectFrom:
By default, MCC stores its settings in `MinecraftClient.ini`, which is created the first time you run the program. You can also pass a custom configuration file path as the first argument when starting MCC. See [Usage](usage.md#quick-usage-of-mcc-with-examples) for examples.
<div class="custom-container warning"><p class="custom-container-title">Warning</p>
</div>
## Notes
- Some less common settings are not repeated here. The generated config file contains inline descriptions for every setting.
@ -126,7 +122,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
This setting defines the account type: `mojang`, `microsoft`, or `yggdrasil`.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Use `microsoft` for normal Microsoft accounts. `yggdrasil` is for custom authlib/Yggdrasil servers.**
@ -391,7 +387,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
MinecraftVersion = "1.18.2"
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Current code support is `1.4.6` through `26.1`.**
@ -413,7 +409,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `no`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Force-enabling only works for MC 1.13 +**
@ -429,7 +425,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `mcc`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**For playing on Hypixel you need to use `vanilla`**
@ -507,6 +503,16 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `true`
#### `ShowEffectNamesInTUI`
- **Description:**
This setting lets you show full effect names and levels in the TUI status bar instead of the compact icon-only effect display.
- **Type:** `boolean`
- **Default:** `false`
#### `TerrainAndMovements`
- **Description:**
@ -523,7 +529,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `false`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Sometimes the latest versions might not support this straight away, since Mojang often makes changes to this.**
@ -561,7 +567,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `false`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Sometimes the latest versions might not support this straight away, since Mojang often makes changes to this.**
@ -615,7 +621,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `true`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Only works on Windows XP-8 or Windows 10 with old console**
@ -661,7 +667,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `false`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Make sure the spawn point is safe**
@ -966,7 +972,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `.*`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Not filtering anything by default**
@ -984,7 +990,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `.*`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Not filtering anything by default**
@ -1022,7 +1028,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
- **Default:** `console-log.txt`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**%username% and %serverip% will be substituted with your username and the IP address of the server you are connected to. So you can use something like: `console-log-%username%-%serverip%.txt`**
@ -1062,7 +1068,7 @@ Coordinate = { x = 145, y = 64, z = 2045 }
To define a variable/setting, simply make a new line with the following format under the `[AppVar.VarStirng]` section:
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`%username%`, `%login%`, `%serverip%`, `%serverport%`, `%datetime%`, `%players%` are reserved read-only variables**

View file

@ -13,7 +13,7 @@ title: Creating Chat Bots
## Notes
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This page covers the basics of the Chat Bot API. For the full surface area, read [ChatBot.cs](https://github.com/MCCTeam/Minecraft-Console-Client/blob/master/MinecraftClient/Scripting/ChatBot.cs) and the example scripts linked below.**
@ -41,7 +41,7 @@ More in-depth:
This introduction assumes that you have the basic knowledge of C#.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**In this page, "Chat Bot" and "Script" are used interchangeably.**
@ -124,7 +124,7 @@ MCC.LoadBot(new YourChatBotClassNameHere());
The **Script Metadata** section also lets you include namespaces and DLL references with `//using <namespace>` and `//dll <dll name>`.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Avoid adding whitespace between `//` and keywords**
@ -176,7 +176,7 @@ When the Chat Bot is initialized for the first time, the `Initialize` method is
Use it to initialize state such as dictionaries or cached values.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**For allocating resources like a database connection, we recommend allocating them in `AfterGameJoined` and freeing them in `OnDisconnect`**
@ -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.
@ -38,7 +75,7 @@ Requirements:
- [Git](https://www.git-scm.com/)
- [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download) or [Visual Studio](https://visualstudio.microsoft.com/) configured for C# app development
::: tip
::: note
If you want to modify the code and you are new to C# or programming in general, the tutorials listed in [Creating Bots](creating-bots.md#requirements) are a good starting point.
:::
@ -129,7 +166,7 @@ If the publish step succeeds, the published binary `MinecraftClient.exe` will be
<details>
<summary>Linux and macOS build instructions</summary>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you're using Linux we will assume that you should be able to install git on your own. If you don't know how, search it up for your distribution, it should be easy. (Debian based distros: `apt install git`, Arch based: `pacman -S git`)**
@ -187,7 +224,7 @@ git clone https://github.com/MCCTeam/Minecraft-Console-Client.git --recursive
dotnet publish MinecraftClient.sln -f net10.0 -r linux-x64 --self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you are using Linux on ARM, 32-bit, RHEL-based distributions, or Musl, [pick the appropriate RID](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog#linux-rids) for your platform and replace `-r linux-x64` with it, for example `-r linux-arm64`.**
@ -199,7 +236,7 @@ git clone https://github.com/MCCTeam/Minecraft-Console-Client.git --recursive
dotnet publish MinecraftClient.sln -f net10.0 -r osx-x64 --self-contained=true -c Release -p:UseAppHost=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you are not using an Intel Mac, [pick the appropriate RID](https://learn.microsoft.com/en-us/dotnet/core/rid-catalog#macos-rids) for your processor and replace `-r osx-x64` with it, for example `-r osx-arm64`.**
@ -228,7 +265,7 @@ Requirements:
- Git
- Docker
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This section is for more advanced users, if you do not know how to install git or docker, you can take a look at other sections for Git, and search on how to install Docker on your system.**
@ -316,19 +353,19 @@ docker-compose down
It is possible to run Minecraft Console Client on Android through Termux and Ubuntu, but it requires a manual setup with a lot of commands, so be careful not to skip any steps. Depending on your technical background, internet speed, and device speed, this can take anywhere from 10 to 20 minutes or more.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This section gets a bit technical. If you run into issues, open a discussion on our GitHub repository page.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You're required to have some bare basic knowledge of Linux, if you do not know anything about it, watch [this video](https://www.youtube.com/watch?v=SkB-eRCzWIU) to get familiar with basic commands.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Here we're installing everything on the root account for simplicity sake, if you want to make a user account, make sure you update the command which reference the `/root` directory with your home directory.**
@ -351,7 +388,7 @@ It is possible to run Minecraft Console Client on Android through Termux and Ubu
**GitHub releases:** Go to [the latest Termux GitHub release](https://github.com/termux/termux-app/releases/latest/), download the APK file whose name contains `universal` (e.g. `termux-app_v...-debug_universal.apk`), and install it.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If your file manager does not let you install APK files, install and use `File Manager +` and grant it permission to install third-party applications when asked.**
@ -373,7 +410,7 @@ Open Termux and run the following commands one at a time, in order:
2. `pkg upgrade`
3. `pkg install proot-distro`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you are asked to press Y/N during the update or upgrade step, enter Y and press Enter.**
@ -391,7 +428,7 @@ Once the installation finishes, start Ubuntu with:
proot-distro login ubuntu
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Every time you open Termux after it has been closed, run this command to get back into Ubuntu.**
@ -470,7 +507,7 @@ wget -O MinecraftClient \
| cut -d '"' -f 4)"
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you have a 32-bit ARM device, replace `linux-arm64` with `linux-arm` in the command above.**
@ -517,7 +554,7 @@ Also, here are some linux tutorials for people who are new to it:
## Run on a VPS
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This is a newer section. If you spot a mistake, please report it by opening an issue in our [GitHub repository](https://github.com/MCCTeam/Minecraft-Console-Client).**
@ -548,7 +585,7 @@ Here is a [YouTube video](https://youtu.be/42fwh_1KP_o) that explains it in more
Download and install [Git Bash](https://git-scm.com/downloads).
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Make sure to allow the installation to add it to the context menu**
@ -602,7 +639,7 @@ Some of the reliable and cheap hosting providers (sorted for price/performance):
**Minimum price**: `2.50 EUR / month`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If Ubuntu 24.04 LTS is not in the dropdown when ordering, you may need to reinstall later or ask support to do it.**
@ -648,7 +685,7 @@ You also may want to search for better deals.
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you're not banned, sometimes fetching the keys can take some time, try giving it a minute or two, if it still hangs, hit some keys to refresh the screen, or try restarting and running again. If it still happens, use tmux instead of screen.**
@ -665,7 +702,7 @@ Once you're done, you can continue to [Setting up the Amazon VPS](#setting-up-an
<details>
<summary>AWS EC2 setup steps</summary>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Skip this section if you're not using AWS. Go to [Initial VPS setup](#initial-vps-setup)**
@ -673,7 +710,7 @@ Once you're done, you can continue to [Setting up the Amazon VPS](#setting-up-an
When you register and open the `AWS Console`, click on the Search field on the top of the page and search for: `EC2`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Make sure to select the region closest to you for the minimal latency**
@ -713,7 +750,7 @@ For the **Network settings** check the following checkboxes on:
- `Allow HTTPs traffic from the internet`
- `Allow HTTP traffic from the internet`
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**The SSH traffic from Anywhere is not the best thing for security, you might want to enter IP addresses of your devices from which you want to access the VPS manually.**
@ -737,13 +774,13 @@ In order to login with SSH, you are going to use the following command:
ssh -i <name of your private root key here> ubuntu@<your public dns v4 ip here>
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`<` and `>` are not typed, that is just a notation for a placeholder!**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`ubuntu` is a default root account username for Ubuntu on AWS!**
@ -766,7 +803,7 @@ Now you can continue to [Creating a new user](#creating-a-new-user)
<details>
<summary>Non-AWS VPS login steps</summary>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This section if for those who do not use AWS, if you use AWS skip it**
@ -784,7 +821,7 @@ If you're on Windows open `Git Bash`, on mac OS and Linux open a `Terminal` and
ssh <username>@<ip>
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you're given a custom port other than `22` by your host, you should add `-p <port here>` before the username (eg. `ssh -p <port here> <username>@<ip>`) or `:<port>` after the ip (eg. `ssh <username>@<ip>:<port>`)**
@ -815,7 +852,7 @@ Once you've logged in to your VPS you need to create a new user and give it SSH
In this tutorial we will be using `mcc` as a name for the user account that will be running the MCC.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You may be wondering why we're creating a separate user account and making it be accessible over SSH only. This is for security reasons, if you do not want to do this, you're free to skip it, but be careful.**
@ -833,13 +870,13 @@ Now we need to give it a password, execute the following command, type the passw
sudo passwd mcc
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**When you're typing a password it will not be displayed on the screen, but you're typing it for real.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Make sure you have a strong password!**
@ -993,7 +1030,7 @@ Example:
ssh -i MCC_Key mcc@3.71.108.69
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If you've changed the `Port`, make sure you add a `-p <your port here>` option after the `-i <key>` option (eg. `ssh -i MCC_Key -p 8973 mcc@3.71.108.69`)!**
@ -1012,7 +1049,7 @@ Now you can install the .NET 10 SDK and MCC.
<details>
<summary>.NET SDK installation on VPS</summary>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**If your VPS has an ARM CPU, follow [this](#installing-net-on-arm) part of the documentation and then return to section after this one.**
@ -1073,7 +1110,7 @@ If it was successful, you can now install MCC.
Now that you have the .NET SDK and a user account, install the `screen` utility. You will need it if you want MCC to keep running after you close the SSH session.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**There is also a Docker method, if you're using Docker, you do not need the `screen` program.**
@ -1107,13 +1144,13 @@ To start a screen, type:
screen -S mcc
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`mcc` here is the name of the screen, you can use whatever you like, but if you've used a different name, make sure you use that one instead of the `mcc` in the following commands.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to make a screen only once, however if you reboot your VPS, you need to start it on each reboot.**

View file

@ -106,7 +106,7 @@ MCC also supports a few maintenance and debugging switches such as `--upgrade`,
### Quick usage of MCC with examples
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**On Linux and macOS, you need to type: `./MinecraftClient` instead of `MinecraftClient.exe`**
@ -120,7 +120,7 @@ MinecraftClient.exe --section.setting=value [--other settings]
MinecraftClient.exe <settings-file.ini> [--other settings]
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Microsoft accounts use the OAuth 2.0 device code flow and do not require a password on the command line. MCC will display a code and a URL for you to sign in through your browser (with full 2FA support). You can simply omit the password or use `""` as a placeholder.**
@ -198,7 +198,7 @@ From chat prompt, commands must by default be prepended with a slash, eg. `/quit
In scripts and remote control, no slash is needed to perform the command, eg. `quit`.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Some commands may not be documented yet or are defined in description of Chat Bots, use `/help` to list them all, or you can contribute to this page.**
@ -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>
@ -261,7 +309,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
<details>
<summary><code>blockinfo</code></summary>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Terrain And Movements](configuration.md#terrainandmovements) enabled in order for this to work.**
@ -321,7 +369,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Change your selected slot in the hotbar.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<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 to work.**
@ -348,7 +396,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need a terminal with emoji support, like Powershell 7, Windows Terminal or Alacritty, if you do not want emoji support and want to use cmd or powershell 5, disable emojis with: [`enableemoji`](configuration.md#enableemoji)**
@ -400,7 +448,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Drop all items of a specific type from your inventory.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<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 to work.**
@ -412,7 +460,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/dropitem <itemtype>
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**All item types can be found [here](https://mccteam.github.io/r/item/#L12).**
@ -429,7 +477,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
<details>
<summary><code>enchant</code></summary>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<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 to work.**
@ -453,6 +501,21 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
</details>
<details>
<summary><code>effects</code></summary>
- **Description:**
Lists the status effects currently applied to your player.
- **Usage:**
```
/effects
```
</details>
<details>
<summary><code>entity</code></summary>
@ -460,7 +523,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Attack an entity, use an entity or get a list of entities around you.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Inventory Handling](configuration.md#inventoryhandling) and [Entity Handling](configuration.md#entityhandling) enabled in order for this to work.**
@ -480,7 +543,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/entity
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**All entity types can be found [here](https://mccteam.github.io/r/entity/#L15).**
@ -509,7 +572,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Also the instance of MCC is available with `MCC.`.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**All local variables are treated as strings in the app, when comparing their values, you can use `<variable> == "<value>"`, or better use [`.Equals`](https://www.programiz.com/csharp-programming/library/string/equals) method**
@ -528,7 +591,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/execif 'test == "Something"' "send Success!"
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You can use single quote (`'`) to wrap your expression if the expression contains double quote (`"`)**
@ -606,7 +669,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/reco [account]
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`[account]` is an account alias defined in accounts file, for more info check out [accountlist](configuration.html#accountlist)**
@ -621,7 +684,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Reloads the active configuration file and chat bots.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Some settings are not reloaded because they are used before client initialization. Settings passed on the command line also override file values.**
@ -635,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>
@ -648,13 +784,13 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/connect <server> [account]
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`<server>` is either a server IP or a server alias defined in servers file, for more info check out [serverlist](configuration.html#serverlist)**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**`[account]` is an account alias defined in accounts file, for more info check out [accountlist](configuration.html#accountlist)**
@ -817,14 +953,38 @@ 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>
- **Description:**
Use item in the hand, this can be used to do a right click on items which open menus on servers.
Use the item in your hand, including use-on-block actions like shovel flattening.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<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 to work.**
@ -842,6 +1002,12 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/useitem
```
Use the item on a specific block:
```
/useitem <x> <y> <z>
```
</details>
<details>
@ -859,13 +1025,13 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
- shulker
- loom
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Inventory Handling](configuration.md#inventoryhandling) and [Terrain and Movements](configuration.md#terrainandmovements) enabled in order for this to work.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Not all inventories have a GUI representation in an ASCII art format.**
@ -898,21 +1064,21 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Make the bot follow a player.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This command is available only when the [Follow Player](chat-bots.md#follow-player) chat bot is enabled.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<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 to work.**
</div>
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Enity Handling](configuration.md#entityhandling) enabled in order for this to work.**
**You need to have [Entity Handling](configuration.md#entityhandling) enabled in order for this to work.**
</div>
@ -980,7 +1146,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Used for moving when terrain and movements feature is enabled.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**You need to have [Terrain and Movements](configuration.md#terrainandmovements) enabled in order for this to work.**
@ -1100,7 +1266,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Used for inventory manipulation.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<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 to work.**
@ -1116,7 +1282,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Inventory has slots and each one of them has an id.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**This command DOES NOT physically open a container (eg. chest), for that you need to use [`useblock`](#useblock) command first.**
@ -1134,7 +1300,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/inventory <player|container|<id>> <action> [action parameters] | /inventory <inventories/i> | /inventory <search/s> <item type> [amount]
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**player and container can be simplified with p and c accordingly**
@ -1157,7 +1323,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/inventory <player|container|<id>> <click> <slot id> [left|right|middle|Shift|ShiftRight]
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**The default click is left click**
@ -1175,7 +1341,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/inventory <player|id> drop <slot id> <number of items|all>
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**To drop all items from a slot, you can use: `all`**
@ -1187,7 +1353,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/inventory creativegive <slot id> <item type> <amount>
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**To find item types, check out [this list](https://mccteam.github.io/r/item/#L12)**
@ -1261,7 +1427,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
/inventory creativegive 36 diamondblock 64
```
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**To find item types, check out [this list](https://mccteam.github.io/r/item/#L12)**
@ -1297,7 +1463,7 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
Show commands help.
<div class="custom-container tip"><p class="custom-container-title">Tip</p>
<div class="custom-container note"><p class="custom-container-title">Note</p>
**Use "/send /help" for server help**

View file

@ -117,6 +117,14 @@ These are useful if your client needs a name-to-ID lookup for the current MCC ve
- [Commands](Commands.md) - full list of available commands
- [Events](Events.md) - full list of emitted events
<div class="custom-container tip"><p class="custom-container-title">⭐ Reference Implementation: MCC.js</p>
[MCC.js](https://github.com/milutinke/MCC.js) is a Node.js/TypeScript library built for this bot. It handles authentication, JSON serialization, event subscriptions, and typed command wrappers out of the box.
If you're writing a client in JavaScript or TypeScript, start there.
</div>
## Compatibility
- Requires any MCC version that supports `/script` (standalone MCCScript 1.0 bots).

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"

Some files were not shown because too many files have changed in this diff Show more