CBOT V1.3

This commit is contained in:
Feror 2026-06-01 11:07:34 +02:00
commit 8e7ec034f2
31 changed files with 3524 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.gradle/
build/
out/
run/
*.iml
.idea/
*.class
.DS_Store
node_modules/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

123
README.md Normal file
View file

@ -0,0 +1,123 @@
# Carpet Bot Manager (`cbot`)
Fabric mod that adds player-owned bot management on top of Fabric Carpet's `/player` command.
The mod can run server-side only for command-based management. If the same jar is installed on both the server and the client, players can also manage their own bots through a GUI.
## Dependencies
- Minecraft `1.21.11` (Yarn mapped)
- Fabric Loader
- Fabric API
- Fabric Carpet (for `/player`-based spawn/actions)
Build system: Gradle + Fabric Loom (project files included).
If Carpet is missing, `/cbot` still registers, but Carpet-backed actions (`spawn` and forwarded actions) return:
`Carpet mod is not installed`.
## Client GUI
Install `cbot` on both the server and client to enable the GUI.
- `/cbot gui`
- Opens the client GUI when the player has the client mod installed.
- Without the client mod, the server returns `Install cbot on your client to use the GUI`.
- Keybind: `Open Carpet Bot Manager`
- Default is unbound.
- Configure it in Minecraft controls under `Carpet Bot Manager`.
The GUI is personal bot management only. Admin configuration remains command-only.
GUI actions are still server-authoritative:
- The client sends only bot IDs and action parameters.
- The server resolves bot ownership from the executing player.
- Bot actions still dispatch only restricted `player <ownedBotName> ...` or `tp <ownedBotName> ...` commands.
## Commands
- `/cbot list`
- Shows owned bot IDs, names, and online status.
- `/cbot buy`
- Buys one additional bot using the configured price.
- `/cbot gui`
- Opens the client GUI if the client also has cbot installed.
- `/cbot <id> spawn`
- Runs `player <botName> spawn` as server source.
- `/cbot <id> tp`
- Runs `tp <botName> <playerName>`.
- `/cbot <id> tp <x> <y> <z>`
- Runs `tp <botName> <x> <y> <z>`.
- `/cbot <id> <carpetAction...>`
- Forwards to `player <botName> <carpetAction...>`.
- Provides autocomplete hints for common Carpet actions while still forwarding the action text unchanged.
Admin-only config commands require permission level 4:
- `/cbot config show`
- Shows the current bot name pattern, free bot count, price, and cap.
- `/cbot config botname "<pattern>"`
- Sets the bot name pattern and renames saved bot identities.
- Supported placeholders: `{player}`, `{id}`, `{uuid}`.
- Generated bot names are sanitized and deterministically shortened to Minecraft's 16-character player-name limit.
- `/cbot config starting-free <count>`
- Sets how many free bots players are topped up to on next join or `/cbot` use.
- `/cbot config price <item> <count>`
- Sets the buy price, for example `minecraft:diamond 2`.
- A count of `0` makes buying free.
- `/cbot config cap <count>`
- Sets the maximum total bots per player.
- `/cbot config cap none`
- Removes the bot cap.
- `/cbot config reset`
- Restores defaults and renames saved bot identities back to the default pattern.
## Ownership and persistence
- Ownership is stored per world in a `PersistentState`:
- `Map<UUID, List<String>> botNames`.
- By default, a player gets exactly one starter bot:
- `cbot_<ownerName>_1`.
- Additional bots are deterministic and per-owner:
- `cbot_<ownerName>_<n>`.
- Defaults:
- Bot name pattern: `cbot_{player}_{id}`
- Starting free bots: `1`
- Price: `1` `minecraft:netherite_block`
- Bot cap: none
- Changing `botname` renames saved identities immediately. Already-spawned old-name fake players are not killed automatically.
## Security model
`/cbot` executes underlying commands with elevated server permissions, but only after validation:
- Executor must be a real `ServerPlayerEntity`.
- Bot ID must resolve from that player's owned list.
- Commands are restricted to:
- `player <resolvedBotName> ...`
- `tp <resolvedBotName> ...`
- All dispatches are logged with owner UUID/name, bot name, action, and command string.
This prevents arbitrary command execution through `/cbot`.
## Quick test plan
1. Fresh world: join as PlayerA, run `/cbot list`, verify 1 starter bot.
2. Run `/cbot 1 spawn`, verify bot appears.
3. Run `/cbot 1 use once`, verify forwarding works.
4. Give netherite block, run `/cbot buy`, verify bot #2 appears in `/cbot list`.
5. Join PlayerB, verify PlayerB cannot manage PlayerA's bot IDs.
6. Restart server, verify ownership persists.
7. As an operator, run `/cbot config starting-free 3`, verify an existing player is topped up on the next `/cbot list`.
8. Run `/cbot config botname "cbot{id}_of_{player}"`, verify saved bot names are renamed and stay spawnable.
## E2E tests
Run the Mineflayer-based E2E test with:
```sh
npm run e2e
```
The test starts `./gradlew runServer`, ensures Fabric Carpet is available in `run/mods`, connects real network players with Mineflayer in offline mode, executes `/cbot` commands, checks chat responses, and stops the server.

53
build.gradle Normal file
View file

@ -0,0 +1,53 @@
plugins {
id "fabric-loom" version "${loom_version}"
id "maven-publish"
}
version = project.mod_version
group = project.maven_group
base {
archivesName = project.archives_base_name
}
repositories {
maven {
name = "Fabric"
url = "https://maven.fabricmc.net/"
}
mavenCentral()
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2"
modImplementation "net.fabricmc:fabric-loader:${project.loader_version}"
modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_version}"
}
processResources {
inputs.property "version", project.version
filesMatching("fabric.mod.json") {
expand "version": project.version
}
}
tasks.withType(JavaCompile).configureEach {
it.options.release = 21
}
java {
withSourcesJar()
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
publishing {
publications {
mavenJava(MavenPublication) {
artifactId = project.archives_base_name
from components.java
}
}
}

12
gradle.properties Normal file
View file

@ -0,0 +1,12 @@
org.gradle.jvmargs=-Xmx1G
org.gradle.parallel=true
minecraft_version=1.21.11
yarn_mappings=1.21.11+build.5
loader_version=0.19.2
loom_version=1.15.4
fabric_version=0.141.4+1.21.11
mod_version=1.2.0
maven_group=com.feror
archives_base_name=cbot

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

954
package-lock.json generated Normal file
View file

@ -0,0 +1,954 @@
{
"name": "cbot-e2e",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cbot-e2e",
"version": "1.2.0",
"dependencies": {
"mineflayer": "^4.33.0"
}
},
"node_modules/@azure/msal-common": {
"version": "14.16.1",
"resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz",
"integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/@azure/msal-node": {
"version": "2.16.3",
"resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.3.tgz",
"integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==",
"license": "MIT",
"dependencies": {
"@azure/msal-common": "14.16.1",
"jsonwebtoken": "^9.0.0",
"uuid": "^8.3.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "25.8.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/node-rsa": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.4.tgz",
"integrity": "sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/readable-stream": {
"version": "4.0.23",
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@xboxreplay/xboxlive-auth": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz",
"integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/aes-js": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/asn1": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
"integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz",
"integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==",
"license": "MIT",
"engines": {
"node": ">=0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/discontinuous-range": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/endian-toggle": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/endian-toggle/-/endian-toggle-0.0.0.tgz",
"integrity": "sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==",
"license": "MIT"
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lodash.reduce": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz",
"integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==",
"license": "MIT"
},
"node_modules/macaddress": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/macaddress/-/macaddress-0.5.4.tgz",
"integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==",
"license": "MIT"
},
"node_modules/minecraft-data": {
"version": "3.110.2",
"resolved": "https://registry.npmjs.org/minecraft-data/-/minecraft-data-3.110.2.tgz",
"integrity": "sha512-u0aCCSpQWVreGnZGU/Lu0jmZmc0Y37M0Fvw6eQVQY0BdS/BGRDDU+ug6/qP3QDuZRJCSzi8wNW8ODnOhwpnkpA==",
"license": "MIT"
},
"node_modules/minecraft-folder-path": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz",
"integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==",
"license": "MIT"
},
"node_modules/minecraft-protocol": {
"version": "1.66.2",
"resolved": "https://registry.npmjs.org/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz",
"integrity": "sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/node-rsa": "^1.1.4",
"@types/readable-stream": "^4.0.0",
"aes-js": "^3.1.2",
"buffer-equal": "^1.0.0",
"debug": "^4.3.2",
"endian-toggle": "^0.0.0",
"lodash.merge": "^4.3.0",
"minecraft-data": "^3.78.0",
"minecraft-folder-path": "^1.2.0",
"node-fetch": "^2.6.1",
"node-rsa": "^0.4.2",
"prismarine-auth": "^3.1.1",
"prismarine-chat": "^1.10.0",
"prismarine-nbt": "^2.5.0",
"prismarine-realms": "^1.2.0",
"protodef": "^1.17.0",
"readable-stream": "^4.1.0",
"uuid-1345": "^1.0.1",
"yggdrasil": "^1.4.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/mineflayer": {
"version": "4.37.1",
"resolved": "https://registry.npmjs.org/mineflayer/-/mineflayer-4.37.1.tgz",
"integrity": "sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.108.0",
"minecraft-protocol": "^1.66.0",
"mojangson": "^2.0.4",
"prismarine-biome": "^1.1.1",
"prismarine-block": "^1.22.0",
"prismarine-chat": "^1.7.1",
"prismarine-chunk": "^1.39.0",
"prismarine-entity": "^2.5.0",
"prismarine-item": "^1.17.0",
"prismarine-nbt": "^2.0.0",
"prismarine-physics": "^1.9.0",
"prismarine-recipe": "^1.5.0",
"prismarine-registry": "^1.10.0",
"prismarine-windows": "^2.9.0",
"prismarine-world": "^3.6.0",
"protodef": "^1.18.0",
"typed-emitter": "^1.0.0",
"uuid-1345": "^1.0.2",
"vec3": "^0.1.7"
},
"engines": {
"node": ">=22"
}
},
"node_modules/mojangson": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mojangson/-/mojangson-2.0.4.tgz",
"integrity": "sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==",
"license": "MIT",
"dependencies": {
"nearley": "^2.19.5"
}
},
"node_modules/moo": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz",
"integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==",
"license": "BSD-3-Clause"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
"license": "MIT",
"dependencies": {
"commander": "^2.19.0",
"moo": "^0.5.0",
"railroad-diagrams": "^1.0.0",
"randexp": "0.4.6"
},
"bin": {
"nearley-railroad": "bin/nearley-railroad.js",
"nearley-test": "bin/nearley-test.js",
"nearley-unparse": "bin/nearley-unparse.js",
"nearleyc": "bin/nearleyc.js"
},
"funding": {
"type": "individual",
"url": "https://nearley.js.org/#give-to-nearley"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-rsa": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-0.4.2.tgz",
"integrity": "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==",
"license": "MIT",
"dependencies": {
"asn1": "0.2.3"
}
},
"node_modules/prismarine-auth": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/prismarine-auth/-/prismarine-auth-3.1.1.tgz",
"integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==",
"license": "MIT",
"dependencies": {
"@azure/msal-node": "^2.0.2",
"@xboxreplay/xboxlive-auth": "^5.1.0",
"debug": "^4.3.3",
"smart-buffer": "^4.1.0",
"uuid-1345": "^1.0.2"
}
},
"node_modules/prismarine-biome": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prismarine-biome/-/prismarine-biome-1.4.0.tgz",
"integrity": "sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==",
"license": "MIT",
"peerDependencies": {
"prismarine-registry": "^1.1.0"
}
},
"node_modules/prismarine-block": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/prismarine-block/-/prismarine-block-1.23.0.tgz",
"integrity": "sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.38.0",
"prismarine-biome": "^1.1.0",
"prismarine-chat": "^1.5.0",
"prismarine-item": "^1.10.1",
"prismarine-nbt": "^2.0.0",
"prismarine-registry": "^1.1.0"
}
},
"node_modules/prismarine-chat": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/prismarine-chat/-/prismarine-chat-1.13.0.tgz",
"integrity": "sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.2",
"mojangson": "^2.0.1",
"prismarine-nbt": "^2.0.0",
"prismarine-registry": "^1.4.0"
}
},
"node_modules/prismarine-chunk": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/prismarine-chunk/-/prismarine-chunk-1.40.0.tgz",
"integrity": "sha512-TtT84Bys7+aGA94HwcK0QDp+jkWcLOLErKYtaWWl+EJya28NqPoBASr5L/lPZ8ZWLQUugg/aFIefZI/rEhEQWw==",
"license": "MIT",
"dependencies": {
"prismarine-biome": "^1.2.0",
"prismarine-block": "^1.14.1",
"prismarine-nbt": "^2.2.1",
"prismarine-registry": "^1.1.0",
"smart-buffer": "^4.1.0",
"uint4": "^0.1.2",
"vec3": "^0.1.3",
"xxhash-wasm": "^0.4.2"
},
"engines": {
"node": ">=14"
}
},
"node_modules/prismarine-entity": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/prismarine-entity/-/prismarine-entity-2.6.0.tgz",
"integrity": "sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==",
"license": "MIT",
"dependencies": {
"prismarine-chat": "^1.4.1",
"prismarine-item": "^1.11.2",
"prismarine-registry": "^1.4.0",
"vec3": "^0.1.4"
}
},
"node_modules/prismarine-item": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/prismarine-item/-/prismarine-item-1.18.0.tgz",
"integrity": "sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==",
"license": "MIT",
"dependencies": {
"prismarine-nbt": "^2.0.0",
"prismarine-registry": "^1.4.0"
}
},
"node_modules/prismarine-nbt": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz",
"integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==",
"license": "MIT",
"dependencies": {
"protodef": "^1.18.0"
}
},
"node_modules/prismarine-physics": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/prismarine-physics/-/prismarine-physics-1.11.0.tgz",
"integrity": "sha512-P25VSDi3kJHQAb/AJBiJCQuxyRCVXRSdEiDjx56ywocgt65N/exatVTiJjOK5HgEKHJSfw0sXSAohQhvutnGAA==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.0.0",
"prismarine-nbt": "^2.0.0",
"vec3": "^0.1.7"
}
},
"node_modules/prismarine-realms": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prismarine-realms/-/prismarine-realms-1.6.0.tgz",
"integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.3",
"node-fetch": "^2.6.1"
}
},
"node_modules/prismarine-recipe": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz",
"integrity": "sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==",
"license": "MIT",
"peerDependencies": {
"prismarine-registry": "^1.4.0"
}
},
"node_modules/prismarine-registry": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prismarine-registry/-/prismarine-registry-1.12.0.tgz",
"integrity": "sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==",
"license": "MIT",
"dependencies": {
"minecraft-data": "^3.70.0",
"prismarine-block": "^1.17.1",
"prismarine-nbt": "^2.0.0"
}
},
"node_modules/prismarine-windows": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/prismarine-windows/-/prismarine-windows-2.10.0.tgz",
"integrity": "sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==",
"license": "MIT",
"dependencies": {
"prismarine-item": "^1.12.2",
"prismarine-registry": "^1.7.0",
"typed-emitter": "^2.1.0"
}
},
"node_modules/prismarine-windows/node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/prismarine-world": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/prismarine-world/-/prismarine-world-3.7.0.tgz",
"integrity": "sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==",
"license": "MIT",
"dependencies": {
"vec3": "^0.1.7"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/protodef": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/protodef/-/protodef-1.19.0.tgz",
"integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==",
"license": "MIT",
"dependencies": {
"lodash.reduce": "^4.6.0",
"protodef-validator": "^1.3.0",
"readable-stream": "^4.4.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/protodef-validator": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/protodef-validator/-/protodef-validator-1.4.0.tgz",
"integrity": "sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==",
"license": "MIT",
"dependencies": {
"ajv": "^6.5.4"
},
"bin": {
"protodef-validator": "cli.js"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
"license": "CC0-1.0"
},
"node_modules/randexp": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
"license": "MIT",
"dependencies": {
"discontinuous-range": "1.0.0",
"ret": "~0.1.10"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/ret": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
"license": "MIT",
"engines": {
"node": ">=0.12"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/typed-emitter": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz",
"integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==",
"license": "MIT"
},
"node_modules/uint4": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/uint4/-/uint4-0.1.2.tgz",
"integrity": "sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/uuid-1345": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/uuid-1345/-/uuid-1345-1.0.2.tgz",
"integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==",
"license": "MIT",
"dependencies": {
"macaddress": "^0.5.1"
}
},
"node_modules/vec3": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/vec3/-/vec3-0.1.10.tgz",
"integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==",
"license": "BSD"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/xxhash-wasm": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz",
"integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==",
"license": "MIT"
},
"node_modules/yggdrasil": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/yggdrasil/-/yggdrasil-1.8.0.tgz",
"integrity": "sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"uuid": "^10.0.0"
}
},
"node_modules/yggdrasil/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "cbot-e2e",
"version": "1.2.0",
"private": true,
"type": "module",
"scripts": {
"e2e": "node scripts/e2e-cbot.mjs"
},
"dependencies": {
"mineflayer": "^4.33.0"
}
}

388
scripts/e2e-cbot.mjs Normal file
View file

@ -0,0 +1,388 @@
import { createWriteStream, existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import https from "node:https";
import net from "node:net";
import path from "node:path";
import { spawn } from "node:child_process";
import mineflayer from "mineflayer";
const ROOT = path.resolve(new URL("..", import.meta.url).pathname);
const RUN_DIR = path.join(ROOT, "run");
const MODS_DIR = path.join(RUN_DIR, "mods");
const CARPET_JAR = "fabric-carpet-1.21.11-1.4.194+v251223.jar";
const CARPET_URL = "https://github.com/gnembon/fabric-carpet/releases/download/1.4.194/fabric-carpet-1.21.11-1.4.194%2Bv251223.jar";
const MC_VERSION = "1.21.11";
const TEST_TIMEOUT_MS = 180_000;
const CHAT_TIMEOUT_MS = 15_000;
const startedAt = Date.now();
let serverProcess;
const clients = [];
async function main() {
const port = Number(process.env.CBOT_E2E_PORT || await getFreePort());
const suffix = Date.now().toString(36).slice(-5);
const playerA = `A${suffix}`;
const playerB = `B${suffix}`;
await prepareRunDirectory(port);
serverProcess = startServer();
await waitForServerReady(serverProcess);
assertIncludes(serverProcess.output, "- carpet ", "server loaded Fabric Carpet");
assertIncludes(serverProcess.output, "- cbot ", "server loaded cbot");
serverCommand("cbot config reset");
await delay(750);
const a = await connectPlayer(playerA, port);
await runAndExpect(a, "cbot list", [`#1 cbot_${playerA}_1 [offline]`]);
await runAndExpect(a, "cbot gui", ["Install cbot on your client to use the GUI"]);
await runAndExpect(a, "cbot buy", ["You need 1 minecraft:netherite_block"]);
serverCommand(`give ${playerA} netherite_block 1`);
await delay(750);
await runAndExpect(a, "cbot buy", [`Bought bot #2 (cbot_${playerA}_2)`]);
await runAndExpect(a, "cbot list", [`#2 cbot_${playerA}_2 [offline]`]);
const b = await connectPlayer(playerB, port);
await runAndExpectEither(b, "cbot config show", ["Unknown or incomplete command", "You do not have permission", "Incorrect argument for command"]);
serverCommand(`op ${playerA}`);
await delay(750);
await runAndExpect(a, "cbot config show", ["cbot config:", "starting-free: 1", "price: minecraft:netherite_block x1", "cap: none"]);
await runAndExpect(a, "cbot config starting-free 3", ["Set starting free bots to 3"]);
await runAndExpect(a, "cbot list", [`#3 cbot_${playerA}_3 [offline]`]);
await runAndExpect(a, "cbot config price minecraft:diamond 2", ["Set bot price to minecraft:diamond x2"]);
await runAndExpect(a, "cbot buy", ["You need 2 minecraft:diamond"]);
serverCommand(`give ${playerA} diamond 2`);
await delay(750);
await runAndExpect(a, "cbot buy", [`Bought bot #4 (cbot_${playerA}_4)`]);
await runAndExpect(a, "cbot config price minecraft:diamond 0", ["Set bot price to minecraft:diamond x0"]);
await runAndExpect(a, "cbot buy", [`Bought bot #5 (cbot_${playerA}_5)`]);
await runAndExpect(a, "cbot config cap 5", ["Set bot cap to 5"]);
await runAndExpect(a, "cbot buy", ["You have reached the bot cap"]);
await runAndExpect(a, "cbot config cap 2", ["Set bot cap to 2"]);
await runAndExpect(a, "cbot list", [`#5 cbot_${playerA}_5 [offline]`]);
const completions = await a.tabComplete("/cbot 1 u", true, false, 5000);
assertIncludes(normalizeCompletions(completions).join("\n"), "use", "action autocomplete includes use");
await runAndExpect(a, "cbot config cap none", ["Set bot cap to none"]);
await runAndExpect(a, "cbot config botname \"cbot{id}_of_{player}\"", ["Set botname pattern to: cbot{id}_of_{player}", "Already-spawned old-name bots may need"]);
const renamedA = await runAndCollect(a, "cbot list", ["Your bots:"]);
assertIncludes(renamedA.join("\n"), `#1 cbot1_of_${playerA} [offline]`, "renamed bot list reflects pattern");
assertNotIncludes(renamedA.join("\n"), `cbot_${playerA}_1`, "old bot name no longer listed after rename");
const renamedANames = parseListedBotNames(renamedA);
const longPlayer = `LongName${suffix}ZZZ`.slice(0, 16);
const c = await connectPlayer(longPlayer, port);
const longList = await runAndCollect(c, "cbot list", ["Your bots:", "#3 "]);
const longNames = parseListedBotNames(longList);
if (longNames.length < 3) {
throw new Error(`expected at least 3 long-player bots; got ${longNames.join(", ")}`);
}
for (const name of longNames) {
if (name.length > 16) {
throw new Error(`expected truncated bot name <= 16 chars; got ${name}`);
}
}
await runAndExpect(c, "cbot 1 spawn", [`Spawned ${longNames[0]}`]);
await waitForChatIncludes(c, `${longNames[0]} joined the game`, 30_000);
await runAndExpect(a, "cbot 1 spawn", [`Spawned ${renamedANames[0]}`]);
await waitForChatIncludes(a, `${renamedANames[0]} joined the game`, 30_000);
await runAndExpect(a, "cbot list", [`#1 ${renamedANames[0]} [online]`]);
await runAndExpect(a, "cbot 1 use once", [`Sent action to ${renamedANames[0]}`]);
await runAndExpect(a, "cbot 1 tp", [`Teleported ${renamedANames[0]} to you`]);
await runAndExpect(a, "cbot 1 tp 10 80 10", [`Teleported ${renamedANames[0]}`]);
await runAndExpect(a, "cbot config reset", ["Reset cbot config to defaults"]);
await runAndExpect(b, "cbot list", [`#1 cbot_${playerB}_1 [offline]`]);
await runAndExpect(b, "cbot 2 spawn", ["Invalid bot id"]);
await stopServer();
console.log(`[e2e] passed in ${Math.round((Date.now() - startedAt) / 1000)}s`);
}
async function prepareRunDirectory(port) {
await mkdir(MODS_DIR, { recursive: true });
await ensureCarpetJar();
await writeFile(path.join(RUN_DIR, "eula.txt"), "eula=true\n", "utf8");
await patchServerProperties(port);
}
async function ensureCarpetJar() {
const jarPath = path.join(MODS_DIR, CARPET_JAR);
if (existsSync(jarPath)) {
return;
}
console.log(`[e2e] downloading ${CARPET_JAR}`);
await download(CARPET_URL, jarPath);
}
async function patchServerProperties(port) {
const file = path.join(RUN_DIR, "server.properties");
const existing = existsSync(file) ? await readFile(file, "utf8") : "";
const values = new Map();
for (const line of existing.split(/\r?\n/)) {
if (!line || line.startsWith("#") || !line.includes("=")) {
continue;
}
const [key, ...rest] = line.split("=");
values.set(key, rest.join("="));
}
values.set("server-port", String(port));
values.set("online-mode", "false");
values.set("enforce-secure-profile", "false");
values.set("spawn-protection", "0");
values.set("white-list", "false");
values.set("enable-command-block", "true");
const body = [
"#Minecraft server properties for cbot e2e tests",
...[...values.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`),
"",
].join("\n");
await writeFile(file, body, "utf8");
}
function startServer() {
console.log("[e2e] starting Fabric server");
const child = spawn("./gradlew", ["runServer"], {
cwd: ROOT,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "dumb" },
});
child.output = "";
child.stdout.on("data", (chunk) => {
const text = chunk.toString();
child.output += text;
process.stdout.write(prefixLines(text, "[server] "));
});
child.stderr.on("data", (chunk) => {
const text = chunk.toString();
child.output += text;
process.stderr.write(prefixLines(text, "[server] "));
});
return child;
}
async function waitForServerReady(child) {
await withTimeout(new Promise((resolve, reject) => {
const onExit = (code) => reject(new Error(`server exited before ready with code ${code}`));
const onData = () => {
if (child.output.includes("Done (") && child.output.includes("For help, type \"help\"")) {
child.off("exit", onExit);
child.stdout.off("data", onData);
child.stderr.off("data", onData);
resolve();
}
};
child.on("exit", onExit);
child.stdout.on("data", onData);
child.stderr.on("data", onData);
}), TEST_TIMEOUT_MS, "server startup");
}
async function connectPlayer(username, port) {
console.log(`[e2e] connecting ${username}`);
const bot = mineflayer.createBot({
host: "127.0.0.1",
port,
username,
auth: "offline",
version: MC_VERSION,
});
clients.push(bot);
bot.seenMessages = [];
bot.on("message", (message) => {
const text = message.toString();
bot.seenMessages.push(text);
console.log(`[${username}] ${text}`);
});
await withTimeout(new Promise((resolve, reject) => {
bot.once("spawn", resolve);
bot.once("error", reject);
bot.once("kicked", (reason) => reject(new Error(`${username} kicked: ${reason}`)));
}), CHAT_TIMEOUT_MS, `${username} login`);
await delay(1000);
return bot;
}
async function runAndExpect(bot, command, expectedMessages) {
const start = bot.seenMessages.length;
console.log(`[e2e] /${command}`);
bot.chat(`/${command}`);
await withTimeout(waitUntil(() => {
const received = bot.seenMessages.slice(start);
return expectedMessages.every((expected) => received.some((message) => message.includes(expected)));
}), CHAT_TIMEOUT_MS, `/${command} -> ${expectedMessages.join(", ")}`);
}
async function runAndExpectEither(bot, command, expectedMessages) {
const start = bot.seenMessages.length;
console.log(`[e2e] /${command}`);
bot.chat(`/${command}`);
await withTimeout(waitUntil(() => {
const received = bot.seenMessages.slice(start);
return expectedMessages.some((expected) => received.some((message) => message.includes(expected)));
}), CHAT_TIMEOUT_MS, `/${command} -> one of ${expectedMessages.join(", ")}`);
}
async function runAndCollect(bot, command, expectedMessages) {
const start = bot.seenMessages.length;
console.log(`[e2e] /${command}`);
bot.chat(`/${command}`);
await withTimeout(waitUntil(() => {
const received = bot.seenMessages.slice(start);
return expectedMessages.every((expected) => received.some((message) => message.includes(expected)));
}), CHAT_TIMEOUT_MS, `/${command} -> ${expectedMessages.join(", ")}`);
return bot.seenMessages.slice(start);
}
async function waitForChatIncludes(bot, expected, timeoutMs = CHAT_TIMEOUT_MS) {
await withTimeout(waitUntil(() => {
return bot.seenMessages.some((message) => message.includes(expected));
}), timeoutMs, `chat message containing "${expected}"`);
}
function serverCommand(command) {
console.log(`[console] ${command}`);
serverProcess.stdin.write(`${command}\n`);
}
async function stopServer() {
for (const bot of clients) {
bot.quit();
}
if (!serverProcess || serverProcess.exitCode !== null) {
return;
}
console.log("[e2e] stopping server");
serverProcess.stdin.write("stop\n");
await withTimeout(new Promise((resolve) => serverProcess.once("exit", resolve)), 30_000, "server stop");
}
async function waitUntil(predicate) {
while (!predicate()) {
await delay(100);
}
}
async function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
async function download(url, destination) {
await new Promise((resolve, reject) => {
const request = https.get(url, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
response.resume();
download(response.headers.location, destination).then(resolve, reject);
return;
}
if (response.statusCode !== 200) {
response.resume();
reject(new Error(`download failed with HTTP ${response.statusCode}`));
return;
}
const file = createWriteStream(destination);
response.pipe(file);
file.on("finish", () => file.close(resolve));
file.on("error", reject);
});
request.on("error", reject);
});
}
async function withTimeout(promise, timeoutMs, label) {
let timeout;
try {
return await Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), timeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
}
function assertIncludes(haystack, needle, label) {
if (!haystack.includes(needle)) {
throw new Error(`expected ${label}; missing "${needle}"`);
}
}
function assertNotIncludes(haystack, needle, label) {
if (haystack.includes(needle)) {
throw new Error(`expected ${label}; unexpectedly found "${needle}"`);
}
}
function parseListedBotNames(messages) {
const names = [];
for (const message of messages) {
const match = message.match(/^#\d+ (\S+) \[(?:online|offline)\]$/);
if (match) {
names.push(match[1]);
}
}
return names;
}
function normalizeCompletions(completions) {
return completions.map((completion) => {
if (typeof completion === "string") {
return completion;
}
return completion.match ?? completion.text ?? String(completion);
});
}
function prefixLines(text, prefix) {
return text.split(/(?<=\n)/).map((line) => line ? `${prefix}${line}` : line).join("");
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
process.on("SIGINT", async () => {
await stopServer().catch(() => {});
process.exit(130);
});
main().catch(async (error) => {
console.error(`[e2e] failed: ${error.stack || error.message}`);
await stopServer().catch(() => {});
process.exit(1);
});

12
settings.gradle Normal file
View file

@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = "Fabric"
url = "https://maven.fabricmc.net/"
}
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = "cbot"

View file

@ -0,0 +1,44 @@
package com.feror.cbot;
import com.feror.cbot.command.CbotCommand;
import com.feror.cbot.network.CbotNetworking;
import com.feror.cbot.state.CbotPersistentState;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.network.ServerPlayerEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CbotMod implements ModInitializer {
public static final String MOD_ID = "cbot";
public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
@Override
public void onInitialize() {
CbotNetworking.registerPayloads();
CbotNetworking.registerServerReceivers();
CbotCommand.register();
ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> {
if (isFakePlayer(handler.player)) {
return;
}
CbotPersistentState state = CbotPersistentState.get(server);
state.ensureFreeBots(handler.player.getUuid(), handler.player.getStringifiedName());
});
}
private static boolean isFakePlayer(ServerPlayerEntity player) {
String className = player.getClass().getName().toLowerCase();
if (className.contains("fake")) {
return true;
}
try {
Class<?> carpetFakeClass = Class.forName("carpet.patches.EntityPlayerMPFake");
return carpetFakeClass.isInstance(player);
} catch (ClassNotFoundException ignored) {
return false;
}
}
}

View file

@ -0,0 +1,62 @@
package com.feror.cbot.client;
import com.feror.cbot.CbotMod;
import com.feror.cbot.network.ActionResultS2CPayload;
import com.feror.cbot.network.CbotStateData;
import com.feror.cbot.network.OpenScreenS2CPayload;
import com.feror.cbot.network.RequestStateC2SPayload;
import com.feror.cbot.network.StateS2CPayload;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.option.KeyBinding;
import net.minecraft.client.util.InputUtil;
import net.minecraft.text.Text;
import net.minecraft.util.Identifier;
public class CbotClientMod implements ClientModInitializer {
private static KeyBinding openKey;
@Override
public void onInitializeClient() {
openKey = KeyBindingHelper.registerKeyBinding(new KeyBinding(
"key.cbot.open_gui",
InputUtil.Type.KEYSYM,
InputUtil.UNKNOWN_KEY.getCode(),
KeyBinding.Category.create(Identifier.of(CbotMod.MOD_ID, "keybindings"))
));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (openKey.wasPressed()) {
requestOpen(client);
}
});
ClientPlayNetworking.registerGlobalReceiver(OpenScreenS2CPayload.ID, (payload, context) ->
context.client().setScreen(new CbotScreen(payload.state()))
);
ClientPlayNetworking.registerGlobalReceiver(StateS2CPayload.ID, (payload, context) -> updateOpenScreen(context.client(), payload.state()));
ClientPlayNetworking.registerGlobalReceiver(ActionResultS2CPayload.ID, (payload, context) -> updateOpenScreen(context.client(), payload.state()));
}
public static void requestOpen(MinecraftClient client) {
if (client.player == null) {
return;
}
if (!ClientPlayNetworking.canSend(RequestStateC2SPayload.ID)) {
client.player.sendMessage(Text.literal("This server does not support the cbot GUI"), false);
return;
}
ClientPlayNetworking.send(new RequestStateC2SPayload());
}
private static void updateOpenScreen(MinecraftClient client, CbotStateData state) {
if (client.currentScreen instanceof CbotScreen cbotScreen) {
cbotScreen.setState(state);
} else if (!state.message().isBlank() && client.player != null) {
client.player.sendMessage(Text.literal(state.message()), false);
}
}
}

View file

@ -0,0 +1,256 @@
package com.feror.cbot.client;
import com.feror.cbot.network.ActionC2SPayload;
import com.feror.cbot.network.CbotStateData;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking;
import net.minecraft.client.gui.Click;
import net.minecraft.client.gui.DrawContext;
import net.minecraft.client.gui.screen.Screen;
import net.minecraft.client.gui.widget.ButtonWidget;
import net.minecraft.client.gui.widget.TextFieldWidget;
import net.minecraft.text.Text;
import java.util.List;
public class CbotScreen extends Screen {
private static final int PANEL = 180;
private static final int BUTTON_W = 118;
private static final int BUTTON_H = 20;
private static final int LOGICAL_WIDTH = 530;
private static final int LOGICAL_HEIGHT = 326;
private static final int SCREEN_PADDING = 8;
private CbotStateData state;
private int selectedBotId = 1;
private float contentScale = 1.0F;
private float contentX = 0.0F;
private float contentY = 0.0F;
private TextFieldWidget xField;
private TextFieldWidget yField;
private TextFieldWidget zField;
private TextFieldWidget customActionField;
public CbotScreen(CbotStateData state) {
super(Text.literal("Carpet Bots"));
this.state = state;
}
public void setState(CbotStateData state) {
this.state = state;
if (state.bots().stream().noneMatch(bot -> bot.id() == selectedBotId) && !state.bots().isEmpty()) {
selectedBotId = state.bots().getFirst().id();
}
clearAndInit();
}
@Override
protected void init() {
updateContentTransform();
int left = 16;
int y = 36;
for (CbotStateData.BotData bot : state.bots()) {
ButtonWidget button = ButtonWidget.builder(Text.literal(botLabel(bot)), ignored -> {
selectedBotId = bot.id();
clearAndInit();
}).dimensions(left, y, PANEL - 24, BUTTON_H).build();
button.active = bot.id() != selectedBotId;
addDrawableChild(button);
y += 24;
}
int right = PANEL + 24;
int row = 58;
addButton("Refresh", right, row, () -> send(ActionC2SPayload.refresh()), true);
addButton("Buy Bot", right + 124, row, () -> send(ActionC2SPayload.buy()), state.canBuy());
CbotStateData.BotData selected = selectedBot();
boolean hasSelection = selected != null;
boolean selectedOnline = selected != null && selected.online();
boolean carpet = state.carpetLoaded();
row += 28;
addButton("Spawn", right, row, () -> send(ActionC2SPayload.bot(ActionC2SPayload.Action.SPAWN, selectedBotId)), hasSelection && carpet && !selectedOnline);
addButton("TP to Me", right + 124, row, () -> send(ActionC2SPayload.bot(ActionC2SPayload.Action.TP_TO_SELF, selectedBotId)), hasSelection && selectedOnline);
row += 32;
xField = textField(right, row, 56, "X");
yField = textField(right + 62, row, 56, "Y");
zField = textField(right + 124, row, 56, "Z");
addDrawableChild(xField);
addDrawableChild(yField);
addDrawableChild(zField);
addButton("TP to Coordinates", right + 186, row, this::sendTpToCoords, hasSelection && selectedOnline);
row += 36;
addPreset(right, row, "Stop", "stop", hasSelection && carpet && selectedOnline);
addPreset(right + 124, row, "Kill", "kill", hasSelection && carpet && selectedOnline);
row += 24;
addPreset(right, row, "Use Once", "use once", hasSelection && carpet && selectedOnline);
addPreset(right + 124, row, "Use Cont.", "use continuous", hasSelection && carpet && selectedOnline);
row += 24;
addPreset(right, row, "Attack Once", "attack once", hasSelection && carpet && selectedOnline);
addPreset(right + 124, row, "Attack Cont.", "attack continuous", hasSelection && carpet && selectedOnline);
row += 24;
addPreset(right, row, "Jump", "jump", hasSelection && carpet && selectedOnline);
addPreset(right + 124, row, "Sneak", "sneak", hasSelection && carpet && selectedOnline);
row += 24;
addPreset(right, row, "Unsneak", "unsneak", hasSelection && carpet && selectedOnline);
addPreset(right + 124, row, "Sprint", "sprint", hasSelection && carpet && selectedOnline);
row += 24;
addPreset(right, row, "Unsprint", "unsprint", hasSelection && carpet && selectedOnline);
row += 34;
customActionField = new TextFieldWidget(textRenderer, right, row, 180, 20, Text.literal("Custom Carpet action"));
customActionField.setPlaceholder(Text.literal("e.g. move forward"));
customActionField.setMaxLength(120);
addDrawableChild(customActionField);
addButton("Send Custom", right + 186, row, this::sendCustomAction, hasSelection && carpet && selectedOnline);
}
private TextFieldWidget textField(int x, int y, int width, String placeholder) {
TextFieldWidget field = new TextFieldWidget(textRenderer, x, y, width, 20, Text.literal(placeholder));
field.setPlaceholder(Text.literal(placeholder));
field.setMaxLength(16);
return field;
}
private void addPreset(int x, int y, String label, String action, boolean active) {
addButton(label, x, y, () -> send(ActionC2SPayload.forward(selectedBotId, action)), active);
}
private void addButton(String label, int x, int y, Runnable action, boolean active) {
ButtonWidget button = ButtonWidget.builder(Text.literal(label), ignored -> action.run())
.dimensions(x, y, BUTTON_W, BUTTON_H)
.build();
button.active = active;
addDrawableChild(button);
}
private void sendTpToCoords() {
try {
double x = Double.parseDouble(xField.getText());
double y = Double.parseDouble(yField.getText());
double z = Double.parseDouble(zField.getText());
send(ActionC2SPayload.tpToCoords(selectedBotId, x, y, z));
} catch (NumberFormatException exception) {
setState(state.withMessage("Invalid coordinates"));
}
}
private void sendCustomAction() {
String action = customActionField.getText().trim();
if (action.isEmpty()) {
setState(state.withMessage("Enter a Carpet action first"));
return;
}
send(ActionC2SPayload.forward(selectedBotId, action));
}
private void send(ActionC2SPayload payload) {
if (!ClientPlayNetworking.canSend(ActionC2SPayload.ID)) {
setState(state.withMessage("This server does not support the cbot GUI"));
return;
}
ClientPlayNetworking.send(payload);
}
private CbotStateData.BotData selectedBot() {
return state.bots().stream().filter(bot -> bot.id() == selectedBotId).findFirst().orElse(null);
}
private static String botLabel(CbotStateData.BotData bot) {
return "#" + bot.id() + " " + bot.name() + " [" + (bot.online() ? "online" : "offline") + "]";
}
private void updateContentTransform() {
float availableWidth = Math.max(1.0F, width - SCREEN_PADDING);
float availableHeight = Math.max(1.0F, height - SCREEN_PADDING);
contentScale = Math.min(1.0F, Math.min(availableWidth / LOGICAL_WIDTH, availableHeight / LOGICAL_HEIGHT));
contentX = (width - LOGICAL_WIDTH * contentScale) / 2.0F;
contentY = (height - LOGICAL_HEIGHT * contentScale) / 2.0F;
}
private double toLogicalX(double x) {
return (x - contentX) / contentScale;
}
private double toLogicalY(double y) {
return (y - contentY) / contentScale;
}
private Click toLogicalClick(Click click) {
return new Click(toLogicalX(click.x()), toLogicalY(click.y()), click.buttonInfo());
}
@Override
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
context.fill(0, 0, width, height, 0xC0101010);
double logicalMouseX = toLogicalX(mouseX);
double logicalMouseY = toLogicalY(mouseY);
context.getMatrices().pushMatrix();
context.getMatrices().translate(contentX, contentY);
context.getMatrices().scale(contentScale);
context.fill(10, 26, PANEL, LOGICAL_HEIGHT - 18, 0xAA111111);
context.fill(PANEL + 12, 26, LOGICAL_WIDTH - 10, LOGICAL_HEIGHT - 18, 0xAA111111);
context.drawCenteredTextWithShadow(textRenderer, title, LOGICAL_WIDTH / 2, 10, 0xFFFFFF);
context.drawTextWithShadow(textRenderer, "Your bots", 18, 18, 0xE0E0E0);
context.drawTextWithShadow(textRenderer, "Server policy", PANEL + 24, 18, 0xE0E0E0);
int infoX = PANEL + 24;
int infoY = 34;
context.drawTextWithShadow(textRenderer, "Price: " + state.priceItemId() + " x" + state.priceCount(), infoX, infoY, 0xD0D0D0);
context.drawTextWithShadow(textRenderer, "Cap: " + state.capDisplay() + " | Free: " + state.startingFreeBots(), infoX, infoY + 10, 0xD0D0D0);
context.drawTextWithShadow(textRenderer, "Funds: " + fundsLabel(), infoX, infoY + 20, state.hasCurrency() ? 0x80FF80 : 0xFF8080);
context.drawTextWithShadow(textRenderer, "Carpet: " + (state.carpetLoaded() ? "installed" : "missing"), infoX, infoY + 30, state.carpetLoaded() ? 0x80FF80 : 0xFF8080);
CbotStateData.BotData selected = selectedBot();
String selection = selected == null ? "No bot selected" : "Selected: #" + selected.id() + " " + selected.name();
context.drawTextWithShadow(textRenderer, selection, infoX, infoY + 206, 0xE0E0E0);
if (!state.message().isBlank()) {
context.drawTextWithShadow(textRenderer, state.message(), infoX, LOGICAL_HEIGHT - 32, state.capReached() ? 0xFFDD80 : 0xFFFF80);
}
super.render(context, (int) logicalMouseX, (int) logicalMouseY, delta);
context.getMatrices().popMatrix();
}
private String fundsLabel() {
if (state.priceCount() <= 0) {
return "free";
}
return state.hasCurrency() ? "ready" : "missing";
}
@Override
public void mouseMoved(double mouseX, double mouseY) {
super.mouseMoved(toLogicalX(mouseX), toLogicalY(mouseY));
}
@Override
public boolean mouseClicked(Click click, boolean doubled) {
return super.mouseClicked(toLogicalClick(click), doubled);
}
@Override
public boolean mouseReleased(Click click) {
return super.mouseReleased(toLogicalClick(click));
}
@Override
public boolean mouseDragged(Click click, double offsetX, double offsetY) {
return super.mouseDragged(toLogicalClick(click), offsetX / contentScale, offsetY / contentScale);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
return super.mouseScrolled(toLogicalX(mouseX), toLogicalY(mouseY), horizontalAmount, verticalAmount);
}
@Override
public boolean shouldPause() {
return false;
}
}

View file

@ -0,0 +1,296 @@
package com.feror.cbot.command;
import com.feror.cbot.CbotMod;
import com.feror.cbot.network.CbotNetworking;
import com.feror.cbot.network.OpenScreenS2CPayload;
import com.feror.cbot.service.CbotOperationResult;
import com.feror.cbot.service.CbotService;
import com.feror.cbot.state.CbotPersistentState;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.DoubleArgumentType;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.command.CommandSource;
import net.minecraft.command.argument.IdentifierArgumentType;
import net.minecraft.registry.Registries;
import net.minecraft.server.command.CommandManager;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.text.Text;
import net.minecraft.util.Identifier;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import java.util.List;
import static net.minecraft.server.command.CommandManager.argument;
import static net.minecraft.server.command.CommandManager.literal;
public final class CbotCommand {
private static final String CARPET_MOD_ID = "carpet";
private static final SimpleCommandExceptionType PLAYER_ONLY = new SimpleCommandExceptionType(Text.literal("Only players can use /cbot"));
private static final SimpleCommandExceptionType REAL_PLAYER_ONLY = new SimpleCommandExceptionType(Text.literal("Only real players can use /cbot"));
private static final String[] CARPET_ACTION_HINTS = {
"attack", "attack once", "attack continuous", "attack interval",
"use", "use once", "use continuous", "use interval",
"stop", "kill", "mount", "dismount", "drop", "dropStack", "jump",
"look", "move", "move forward", "move backward", "move left", "move right",
"sneak", "unsneak", "sprint", "unsprint", "swapHands", "turn"
};
private CbotCommand() {}
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
register(dispatcher)
);
}
private static void register(CommandDispatcher<ServerCommandSource> dispatcher) {
dispatcher.register(
literal("cbot")
.then(literal("config")
.requires(CommandManager.requirePermissionLevel(CommandManager.OWNERS_CHECK))
.then(literal("show")
.executes(context -> showConfig(context.getSource())))
.then(literal("botname")
.then(argument("pattern", StringArgumentType.string())
.executes(context -> setBotNamePattern(
context.getSource(),
StringArgumentType.getString(context, "pattern")
))))
.then(literal("starting-free")
.then(argument("count", IntegerArgumentType.integer(0))
.executes(context -> setStartingFreeBots(
context.getSource(),
IntegerArgumentType.getInteger(context, "count")
))))
.then(literal("price")
.then(argument("item", IdentifierArgumentType.identifier())
.suggests((context, builder) -> CommandSource.suggestIdentifiers(Registries.ITEM.getIds(), builder))
.then(argument("count", IntegerArgumentType.integer(0))
.executes(context -> setPrice(
context.getSource(),
IdentifierArgumentType.getIdentifier(context, "item"),
IntegerArgumentType.getInteger(context, "count")
)))))
.then(literal("cap")
.then(literal("none")
.executes(context -> setBotCap(context.getSource(), CbotPersistentState.UNLIMITED_BOT_CAP)))
.then(argument("count", IntegerArgumentType.integer(0))
.executes(context -> setBotCap(
context.getSource(),
IntegerArgumentType.getInteger(context, "count")
))))
.then(literal("reset")
.executes(context -> resetConfig(context.getSource()))))
.then(literal("list")
.executes(context -> listBots(context.getSource())))
.then(literal("buy")
.executes(context -> buyBot(context.getSource())))
.then(literal("gui")
.executes(context -> openGui(context.getSource())))
.then(argument("id", IntegerArgumentType.integer(1))
.then(literal("spawn")
.executes(context -> spawnBot(context.getSource(), IntegerArgumentType.getInteger(context, "id"))))
.then(literal("tp")
.executes(context -> tpToOwner(context.getSource(), IntegerArgumentType.getInteger(context, "id")))
.then(argument("x", DoubleArgumentType.doubleArg())
.then(argument("y", DoubleArgumentType.doubleArg())
.then(argument("z", DoubleArgumentType.doubleArg())
.executes(context -> tpToCoords(
context.getSource(),
IntegerArgumentType.getInteger(context, "id"),
DoubleArgumentType.getDouble(context, "x"),
DoubleArgumentType.getDouble(context, "y"),
DoubleArgumentType.getDouble(context, "z")
))))))
.then(argument("action", StringArgumentType.greedyString())
.suggests((context, builder) -> CommandSource.suggestMatching(CARPET_ACTION_HINTS, builder))
.executes(context -> forwardAction(
context.getSource(),
IntegerArgumentType.getInteger(context, "id"),
StringArgumentType.getString(context, "action")
))))
);
}
private static int showConfig(ServerCommandSource source) {
CbotPersistentState state = CbotPersistentState.get(source.getServer());
source.sendFeedback(() -> Text.literal("cbot config:"), false);
source.sendFeedback(() -> Text.literal("botname: " + state.getBotNamePattern()), false);
source.sendFeedback(() -> Text.literal("starting-free: " + state.getStartingFreeBots()), false);
source.sendFeedback(() -> Text.literal("price: " + state.getPriceItemId() + " x" + state.getPriceCount()), false);
source.sendFeedback(() -> Text.literal("cap: " + state.getBotCapDisplay()), false);
return 1;
}
private static int setBotNamePattern(ServerCommandSource source, String pattern) {
CbotPersistentState state = CbotPersistentState.get(source.getServer());
int renamed = state.setBotNamePatternAndRename(pattern);
source.sendFeedback(() -> Text.literal("Set botname pattern to: " + state.getBotNamePattern()), true);
source.sendFeedback(
() -> Text.literal("Renamed " + renamed + " saved bot identities. Already-spawned old-name bots may need to be killed or restarted manually."),
true
);
return 1;
}
private static int setStartingFreeBots(ServerCommandSource source, int count) {
CbotPersistentState state = CbotPersistentState.get(source.getServer());
state.setStartingFreeBots(count);
source.sendFeedback(() -> Text.literal("Set starting free bots to " + count), true);
return 1;
}
private static int setPrice(ServerCommandSource source, Identifier identifier, int count) {
if (Registries.ITEM.getOptionalValue(identifier).isEmpty()) {
source.sendError(Text.literal("Unknown item: " + identifier));
return 0;
}
CbotPersistentState state = CbotPersistentState.get(source.getServer());
state.setPrice(identifier.toString(), count);
source.sendFeedback(() -> Text.literal("Set bot price to " + identifier + " x" + count), true);
return 1;
}
private static int setBotCap(ServerCommandSource source, int cap) {
CbotPersistentState state = CbotPersistentState.get(source.getServer());
state.setBotCap(cap);
source.sendFeedback(() -> Text.literal("Set bot cap to " + state.getBotCapDisplay()), true);
return 1;
}
private static int resetConfig(ServerCommandSource source) {
CbotPersistentState state = CbotPersistentState.get(source.getServer());
int renamed = state.resetConfigAndRename();
source.sendFeedback(() -> Text.literal("Reset cbot config to defaults"), true);
source.sendFeedback(
() -> Text.literal("Renamed " + renamed + " saved bot identities. Already-spawned old-name bots may need to be killed or restarted manually."),
true
);
return 1;
}
private static int listBots(ServerCommandSource source) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
CbotPersistentState state = CbotPersistentState.get(source.getServer());
state.ensureFreeBots(player.getUuid(), CbotService.playerName(player));
List<String> bots = state.getBots(player.getUuid());
if (bots.isEmpty()) {
source.sendFeedback(() -> Text.literal("You have no bots"), false);
return 1;
}
source.sendFeedback(() -> Text.literal("Your bots:"), false);
for (int i = 0; i < bots.size(); i++) {
String botName = bots.get(i);
boolean online = source.getServer().getPlayerManager().getPlayer(botName) != null;
int id = i + 1;
source.sendFeedback(
() -> Text.literal("#" + id + " " + botName + " [" + (online ? "online" : "offline") + "]"),
false
);
}
return bots.size();
}
private static int buyBot(ServerCommandSource source) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
CbotOperationResult result = CbotService.buyBot(player);
CbotService.sendChatResult(source, result);
return result.success() ? 1 : 0;
}
private static int openGui(ServerCommandSource source) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
if (!CbotNetworking.canOpenGui(player)) {
source.sendError(Text.literal("Install cbot on your client to use the GUI"));
return 0;
}
ServerPlayNetworking.send(player, new OpenScreenS2CPayload(CbotService.buildState(player, "")));
source.sendFeedback(() -> Text.literal("Opening cbot GUI"), false);
return 1;
}
private static int spawnBot(ServerCommandSource source, int id) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
if (!isCarpetLoaded()) {
source.sendError(Text.literal("Carpet mod is not installed"));
return 0;
}
CbotOperationResult result = CbotService.spawnBot(player, id);
CbotService.sendChatResult(source, result);
return result.success() ? 1 : 0;
}
private static int tpToOwner(ServerCommandSource source, int id) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
CbotOperationResult result = CbotService.tpToOwner(player, id);
CbotService.sendChatResult(source, result);
return result.success() ? 1 : 0;
}
private static int tpToCoords(ServerCommandSource source, int id, double x, double y, double z) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
CbotOperationResult result = CbotService.tpToCoords(player, id, x, y, z);
CbotService.sendChatResult(source, result);
return result.success() ? 1 : 0;
}
private static int forwardAction(ServerCommandSource source, int id, String action) throws CommandSyntaxException {
ServerPlayerEntity player = requirePlayer(source);
if (!isCarpetLoaded()) {
source.sendError(Text.literal("Carpet mod is not installed"));
return 0;
}
CbotOperationResult result = CbotService.forwardAction(player, id, action);
CbotService.sendChatResult(source, result);
return result.success() ? 1 : 0;
}
private static ServerPlayerEntity requirePlayer(ServerCommandSource source) throws CommandSyntaxException {
ServerPlayerEntity player;
try {
player = source.getPlayerOrThrow();
} catch (CommandSyntaxException exception) {
throw PLAYER_ONLY.create();
}
if (isFakePlayer(player)) {
throw REAL_PLAYER_ONLY.create();
}
return player;
}
private static boolean isCarpetLoaded() {
return FabricLoader.getInstance().isModLoaded(CARPET_MOD_ID);
}
private static boolean isFakePlayer(ServerPlayerEntity player) {
String className = player.getClass().getName().toLowerCase();
if (className.contains("fake")) {
return true;
}
if (!isCarpetLoaded()) {
return false;
}
try {
Class<?> carpetFakeClass = Class.forName("carpet.patches.EntityPlayerMPFake");
return carpetFakeClass.isInstance(player);
} catch (ClassNotFoundException ignored) {
return false;
}
}
}

View file

@ -0,0 +1,63 @@
package com.feror.cbot.network;
import com.feror.cbot.CbotMod;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
public record ActionC2SPayload(Action action, int botId, double x, double y, double z, String carpetAction) implements CustomPayload {
public static final Id<ActionC2SPayload> ID = new Id<>(Identifier.of(CbotMod.MOD_ID, "action"));
public static final PacketCodec<RegistryByteBuf, ActionC2SPayload> CODEC = PacketCodec.ofStatic(
(buf, payload) -> {
buf.writeEnumConstant(payload.action());
buf.writeInt(payload.botId());
buf.writeDouble(payload.x());
buf.writeDouble(payload.y());
buf.writeDouble(payload.z());
buf.writeString(payload.carpetAction() == null ? "" : payload.carpetAction());
},
buf -> new ActionC2SPayload(
buf.readEnumConstant(Action.class),
buf.readInt(),
buf.readDouble(),
buf.readDouble(),
buf.readDouble(),
buf.readString()
)
);
public static ActionC2SPayload refresh() {
return new ActionC2SPayload(Action.REFRESH, 0, 0, 0, 0, "");
}
public static ActionC2SPayload buy() {
return new ActionC2SPayload(Action.BUY, 0, 0, 0, 0, "");
}
public static ActionC2SPayload bot(Action action, int botId) {
return new ActionC2SPayload(action, botId, 0, 0, 0, "");
}
public static ActionC2SPayload tpToCoords(int botId, double x, double y, double z) {
return new ActionC2SPayload(Action.TP_TO_COORDS, botId, x, y, z, "");
}
public static ActionC2SPayload forward(int botId, String carpetAction) {
return new ActionC2SPayload(Action.FORWARD_ACTION, botId, 0, 0, 0, carpetAction);
}
@Override
public Id<? extends CustomPayload> getId() {
return ID;
}
public enum Action {
BUY,
SPAWN,
TP_TO_SELF,
TP_TO_COORDS,
FORWARD_ACTION,
REFRESH
}
}

View file

@ -0,0 +1,23 @@
package com.feror.cbot.network;
import com.feror.cbot.CbotMod;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
public record ActionResultS2CPayload(boolean success, CbotStateData state) implements CustomPayload {
public static final Id<ActionResultS2CPayload> ID = new Id<>(Identifier.of(CbotMod.MOD_ID, "action_result"));
public static final PacketCodec<RegistryByteBuf, ActionResultS2CPayload> CODEC = PacketCodec.ofStatic(
(buf, payload) -> {
buf.writeBoolean(payload.success());
payload.state().write(buf);
},
buf -> new ActionResultS2CPayload(buf.readBoolean(), CbotStateData.read(buf))
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
}
}

View file

@ -0,0 +1,45 @@
package com.feror.cbot.network;
import com.feror.cbot.service.CbotOperationResult;
import com.feror.cbot.service.CbotService;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.server.network.ServerPlayerEntity;
public final class CbotNetworking {
private CbotNetworking() {}
public static void registerPayloads() {
PayloadTypeRegistry.playC2S().register(RequestStateC2SPayload.ID, RequestStateC2SPayload.CODEC);
PayloadTypeRegistry.playC2S().register(ActionC2SPayload.ID, ActionC2SPayload.CODEC);
PayloadTypeRegistry.playS2C().register(OpenScreenS2CPayload.ID, OpenScreenS2CPayload.CODEC);
PayloadTypeRegistry.playS2C().register(StateS2CPayload.ID, StateS2CPayload.CODEC);
PayloadTypeRegistry.playS2C().register(ActionResultS2CPayload.ID, ActionResultS2CPayload.CODEC);
}
public static void registerServerReceivers() {
ServerPlayNetworking.registerGlobalReceiver(RequestStateC2SPayload.ID, (payload, context) -> {
ServerPlayerEntity player = context.player();
ServerPlayNetworking.send(player, new OpenScreenS2CPayload(CbotService.buildState(player, "")));
});
ServerPlayNetworking.registerGlobalReceiver(ActionC2SPayload.ID, (payload, context) -> {
ServerPlayerEntity player = context.player();
CbotOperationResult result = switch (payload.action()) {
case BUY -> CbotService.buyBot(player);
case SPAWN -> CbotService.spawnBot(player, payload.botId());
case TP_TO_SELF -> CbotService.tpToOwner(player, payload.botId());
case TP_TO_COORDS -> CbotService.tpToCoords(player, payload.botId(), payload.x(), payload.y(), payload.z());
case FORWARD_ACTION -> CbotService.forwardAction(player, payload.botId(), payload.carpetAction());
case REFRESH -> CbotService.refresh(player);
};
ServerPlayNetworking.send(player, new ActionResultS2CPayload(result.success(), result.state()));
});
}
public static boolean canOpenGui(ServerPlayerEntity player) {
return ServerPlayNetworking.canSend(player, OpenScreenS2CPayload.ID)
&& ServerPlayNetworking.canSend(player, StateS2CPayload.ID)
&& ServerPlayNetworking.canSend(player, ActionResultS2CPayload.ID);
}
}

View file

@ -0,0 +1,73 @@
package com.feror.cbot.network;
import net.minecraft.network.PacketByteBuf;
import java.util.ArrayList;
import java.util.List;
public record CbotStateData(
List<BotData> bots,
String priceItemId,
int priceCount,
String capDisplay,
int startingFreeBots,
boolean carpetLoaded,
boolean hasCurrency,
boolean canBuy,
boolean capReached,
String message
) {
public CbotStateData {
bots = List.copyOf(bots);
message = message == null ? "" : message;
}
public void write(PacketByteBuf buf) {
buf.writeCollection(bots, (botBuf, bot) -> bot.write(botBuf));
buf.writeString(priceItemId);
buf.writeInt(priceCount);
buf.writeString(capDisplay);
buf.writeInt(startingFreeBots);
buf.writeBoolean(carpetLoaded);
buf.writeBoolean(hasCurrency);
buf.writeBoolean(canBuy);
buf.writeBoolean(capReached);
buf.writeString(message);
}
public static CbotStateData read(PacketByteBuf buf) {
List<BotData> bots = buf.readList(BotData::read);
return new CbotStateData(
bots,
buf.readString(),
buf.readInt(),
buf.readString(),
buf.readInt(),
buf.readBoolean(),
buf.readBoolean(),
buf.readBoolean(),
buf.readBoolean(),
buf.readString()
);
}
public CbotStateData withMessage(String message) {
return new CbotStateData(bots, priceItemId, priceCount, capDisplay, startingFreeBots, carpetLoaded, hasCurrency, canBuy, capReached, message);
}
public record BotData(int id, String name, boolean online) {
private void write(PacketByteBuf buf) {
buf.writeInt(id);
buf.writeString(name);
buf.writeBoolean(online);
}
private static BotData read(PacketByteBuf buf) {
return new BotData(buf.readInt(), buf.readString(), buf.readBoolean());
}
}
public static CbotStateData empty(String message) {
return new CbotStateData(new ArrayList<>(), "minecraft:netherite_block", 1, "none", 1, false, false, false, false, message);
}
}

View file

@ -0,0 +1,20 @@
package com.feror.cbot.network;
import com.feror.cbot.CbotMod;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
public record OpenScreenS2CPayload(CbotStateData state) implements CustomPayload {
public static final Id<OpenScreenS2CPayload> ID = new Id<>(Identifier.of(CbotMod.MOD_ID, "open_screen"));
public static final PacketCodec<RegistryByteBuf, OpenScreenS2CPayload> CODEC = PacketCodec.ofStatic(
(buf, payload) -> payload.state().write(buf),
buf -> new OpenScreenS2CPayload(CbotStateData.read(buf))
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
}
}

View file

@ -0,0 +1,17 @@
package com.feror.cbot.network;
import com.feror.cbot.CbotMod;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
public record RequestStateC2SPayload() implements CustomPayload {
public static final Id<RequestStateC2SPayload> ID = new Id<>(Identifier.of(CbotMod.MOD_ID, "request_state"));
public static final PacketCodec<RegistryByteBuf, RequestStateC2SPayload> CODEC = PacketCodec.unit(new RequestStateC2SPayload());
@Override
public Id<? extends CustomPayload> getId() {
return ID;
}
}

View file

@ -0,0 +1,20 @@
package com.feror.cbot.network;
import com.feror.cbot.CbotMod;
import net.minecraft.network.RegistryByteBuf;
import net.minecraft.network.codec.PacketCodec;
import net.minecraft.network.packet.CustomPayload;
import net.minecraft.util.Identifier;
public record StateS2CPayload(CbotStateData state) implements CustomPayload {
public static final Id<StateS2CPayload> ID = new Id<>(Identifier.of(CbotMod.MOD_ID, "state"));
public static final PacketCodec<RegistryByteBuf, StateS2CPayload> CODEC = PacketCodec.ofStatic(
(buf, payload) -> payload.state().write(buf),
buf -> new StateS2CPayload(CbotStateData.read(buf))
);
@Override
public Id<? extends CustomPayload> getId() {
return ID;
}
}

View file

@ -0,0 +1,5 @@
package com.feror.cbot.service;
import com.feror.cbot.network.CbotStateData;
public record CbotOperationResult(boolean success, String message, CbotStateData state) {}

View file

@ -0,0 +1,192 @@
package com.feror.cbot.service;
import com.feror.cbot.CbotMod;
import com.feror.cbot.network.CbotStateData;
import com.feror.cbot.state.CbotPersistentState;
import com.feror.cbot.util.CommandDispatchUtil;
import com.feror.cbot.util.InventoryUtil;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.item.Item;
import net.minecraft.registry.Registries;
import net.minecraft.server.command.ServerCommandSource;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.util.Identifier;
import java.util.ArrayList;
import java.util.List;
public final class CbotService {
private static final String CARPET_MOD_ID = "carpet";
private CbotService() {}
public static CbotStateData buildState(ServerPlayerEntity player, String message) {
CbotPersistentState state = CbotPersistentState.get(player.getCommandSource().getServer());
state.ensureFreeBots(player.getUuid(), playerName(player));
List<CbotStateData.BotData> bots = new ArrayList<>();
List<String> botNames = state.getBots(player.getUuid());
for (int i = 0; i < botNames.size(); i++) {
String botName = botNames.get(i);
boolean online = player.getCommandSource().getServer().getPlayerManager().getPlayer(botName) != null;
bots.add(new CbotStateData.BotData(i + 1, botName, online));
}
boolean capAllowsBuy = state.canBuyBot(player.getUuid());
Item priceItem = resolveItem(state.getPriceItemId());
boolean hasCurrency = state.getPriceCount() <= 0
|| (priceItem != null && InventoryUtil.count(player, priceItem) >= state.getPriceCount());
boolean canBuy = capAllowsBuy && hasCurrency;
return new CbotStateData(
bots,
state.getPriceItemId(),
state.getPriceCount(),
state.getBotCapDisplay(),
state.getStartingFreeBots(),
isCarpetLoaded(),
hasCurrency,
canBuy,
!capAllowsBuy,
message == null ? "" : message
);
}
public static CbotOperationResult buyBot(ServerPlayerEntity player) {
CbotPersistentState state = CbotPersistentState.get(player.getCommandSource().getServer());
state.ensureFreeBots(player.getUuid(), playerName(player));
if (!state.canBuyBot(player.getUuid())) {
return result(player, false, "You have reached the bot cap");
}
Item priceItem = resolveItem(state.getPriceItemId());
if (priceItem == null) {
return result(player, false, "Configured bot price item is invalid: " + state.getPriceItemId());
}
int priceCount = state.getPriceCount();
if (!InventoryUtil.consume(player, priceItem, priceCount)) {
return result(player, false, "You need " + priceCount + " " + state.getPriceItemId());
}
CbotPersistentState.OwnedBot newBot = state.buyBot(player.getUuid(), playerName(player));
return result(player, true, "Bought bot #" + newBot.id() + " (" + newBot.name() + ")");
}
public static CbotOperationResult spawnBot(ServerPlayerEntity player, int id) {
if (!isCarpetLoaded()) {
return result(player, false, "Carpet mod is not installed");
}
String botName = resolveOwnedBot(player, id);
if (botName == null) {
return result(player, false, "Invalid bot id");
}
String command = "player " + botName + " spawn";
logAction(player, botName, "spawn", command);
int commandResult = CommandDispatchUtil.executeAsServer(player.getCommandSource(), command);
if (commandResult <= 0) {
return result(player, false, "Failed to spawn " + botName);
}
return result(player, true, "Spawned " + botName);
}
public static CbotOperationResult tpToOwner(ServerPlayerEntity player, int id) {
String botName = resolveOwnedBot(player, id);
if (botName == null) {
return result(player, false, "Invalid bot id");
}
String command = "tp " + botName + " " + playerName(player);
logAction(player, botName, "tp_to_player", command);
int commandResult = CommandDispatchUtil.executeAsServer(player.getCommandSource(), command);
if (commandResult <= 0) {
return result(player, false, "Failed to teleport " + botName);
}
return result(player, true, "Teleported " + botName + " to you");
}
public static CbotOperationResult tpToCoords(ServerPlayerEntity player, int id, double x, double y, double z) {
String botName = resolveOwnedBot(player, id);
if (botName == null) {
return result(player, false, "Invalid bot id");
}
String command = "tp " + botName + " " + x + " " + y + " " + z;
logAction(player, botName, "tp_to_coords", command);
int commandResult = CommandDispatchUtil.executeAsServer(player.getCommandSource(), command);
if (commandResult <= 0) {
return result(player, false, "Failed to teleport " + botName);
}
return result(player, true, "Teleported " + botName);
}
public static CbotOperationResult forwardAction(ServerPlayerEntity player, int id, String action) {
if (!isCarpetLoaded()) {
return result(player, false, "Carpet mod is not installed");
}
String botName = resolveOwnedBot(player, id);
if (botName == null) {
return result(player, false, "Invalid bot id");
}
String command = "player " + botName + " " + action;
logAction(player, botName, action, command);
int commandResult = CommandDispatchUtil.executeAsServer(player.getCommandSource(), command);
if (commandResult <= 0) {
return result(player, false, "Failed to run action on " + botName);
}
return result(player, true, "Sent action to " + botName);
}
public static CbotOperationResult refresh(ServerPlayerEntity player) {
return result(player, true, "Refreshed");
}
public static void sendChatResult(ServerCommandSource source, CbotOperationResult result) {
if (result.success()) {
source.sendFeedback(() -> net.minecraft.text.Text.literal(result.message()), false);
} else {
source.sendError(net.minecraft.text.Text.literal(result.message()));
}
}
private static CbotOperationResult result(ServerPlayerEntity player, boolean success, String message) {
return new CbotOperationResult(success, message, buildState(player, message));
}
private static String resolveOwnedBot(ServerPlayerEntity player, int id) {
CbotPersistentState state = CbotPersistentState.get(player.getCommandSource().getServer());
state.ensureFreeBots(player.getUuid(), playerName(player));
return state.getBotName(player.getUuid(), id);
}
private static Item resolveItem(String itemId) {
Identifier identifier = Identifier.tryParse(itemId);
if (identifier == null) {
return null;
}
return Registries.ITEM.getOptionalValue(identifier).orElse(null);
}
public static boolean isCarpetLoaded() {
return FabricLoader.getInstance().isModLoaded(CARPET_MOD_ID);
}
public static String playerName(ServerPlayerEntity player) {
return player.getStringifiedName();
}
private static void logAction(ServerPlayerEntity owner, String botName, String action, String dispatchedCommand) {
CbotMod.LOGGER.info(
"cbot dispatch: owner={} ({}) bot={} action='{}' cmd='{}'",
playerName(owner),
owner.getUuid(),
botName,
action,
dispatchedCommand
);
}
}

View file

@ -0,0 +1,365 @@
package com.feror.cbot.state;
import com.feror.cbot.CbotMod;
import com.mojang.serialization.Codec;
import com.mojang.serialization.codecs.RecordCodecBuilder;
import net.minecraft.datafixer.DataFixTypes;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.PersistentState;
import net.minecraft.world.PersistentStateManager;
import net.minecraft.world.PersistentStateType;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CbotPersistentState extends PersistentState {
public static final String DEFAULT_BOT_NAME_PATTERN = "cbot_{player}_{id}";
public static final int DEFAULT_STARTING_FREE_BOTS = 1;
public static final String DEFAULT_PRICE_ITEM_ID = "minecraft:netherite_block";
public static final int DEFAULT_PRICE_COUNT = 1;
public static final int UNLIMITED_BOT_CAP = -1;
private static final String DATA_NAME = CbotMod.MOD_ID + "_state";
private static final int MAX_BOT_NAME_LENGTH = 16;
private static final Pattern UNSAFE_NAME_CHARS = Pattern.compile("[^a-zA-Z0-9_]");
private static final Pattern OLD_DEFAULT_NAME = Pattern.compile("^cbot_(.+)_\\d+$");
private static final Codec<CbotPersistentState> CODEC = RecordCodecBuilder.create(instance ->
instance.group(
OwnerData.CODEC.listOf().optionalFieldOf("owners", List.of()).forGetter(CbotPersistentState::toOwnerData),
ConfigData.CODEC.optionalFieldOf("config", ConfigData.DEFAULT).forGetter(CbotPersistentState::toConfigData)
).apply(instance, CbotPersistentState::fromCodecData)
);
public static final PersistentStateType<CbotPersistentState> TYPE = new PersistentStateType<>(
DATA_NAME,
CbotPersistentState::new,
CODEC,
DataFixTypes.LEVEL
);
private final Map<UUID, List<String>> ownedBots = new HashMap<>();
private final Map<UUID, String> ownerNames = new HashMap<>();
private String botNamePattern = DEFAULT_BOT_NAME_PATTERN;
private int startingFreeBots = DEFAULT_STARTING_FREE_BOTS;
private String priceItemId = DEFAULT_PRICE_ITEM_ID;
private int priceCount = DEFAULT_PRICE_COUNT;
private int botCap = UNLIMITED_BOT_CAP;
public static CbotPersistentState get(MinecraftServer server) {
PersistentStateManager manager = server.getOverworld().getPersistentStateManager();
return manager.getOrCreate(TYPE);
}
private static CbotPersistentState fromCodecData(List<OwnerData> owners, ConfigData config) {
CbotPersistentState state = new CbotPersistentState();
state.botNamePattern = config.botNamePattern();
state.startingFreeBots = Math.max(0, config.startingFreeBots());
state.priceItemId = config.priceItemId();
state.priceCount = Math.max(0, config.priceCount());
state.botCap = config.botCap() < 0 ? UNLIMITED_BOT_CAP : config.botCap();
for (OwnerData owner : owners) {
UUID uuid;
try {
uuid = UUID.fromString(owner.uuid());
} catch (IllegalArgumentException ignored) {
continue;
}
if (!owner.bots().isEmpty()) {
state.ownedBots.put(uuid, new ArrayList<>(owner.bots()));
}
String ownerName = owner.ownerName();
if (ownerName.isBlank()) {
ownerName = inferOwnerName(owner.bots());
}
if (!ownerName.isBlank()) {
state.ownerNames.put(uuid, ownerName);
}
}
return state;
}
private List<OwnerData> toOwnerData() {
List<OwnerData> owners = new ArrayList<>();
for (Map.Entry<UUID, List<String>> entry : ownedBots.entrySet()) {
if (entry.getValue().isEmpty()) {
continue;
}
String ownerName = ownerNames.getOrDefault(entry.getKey(), inferOwnerName(entry.getValue()));
owners.add(new OwnerData(entry.getKey().toString(), ownerName, List.copyOf(entry.getValue())));
}
return owners;
}
private ConfigData toConfigData() {
return new ConfigData(botNamePattern, startingFreeBots, priceItemId, priceCount, botCap);
}
public List<String> getBots(UUID ownerUuid) {
List<String> bots = ownedBots.get(ownerUuid);
if (bots == null) {
return List.of();
}
return List.copyOf(bots);
}
public String getBotName(UUID ownerUuid, int id) {
if (id <= 0) {
return null;
}
List<String> bots = ownedBots.get(ownerUuid);
if (bots == null || id > bots.size()) {
return null;
}
return bots.get(id - 1);
}
public void ensureFreeBots(UUID ownerUuid, String ownerName) {
rememberOwnerName(ownerUuid, ownerName);
List<String> bots = ownedBots.computeIfAbsent(ownerUuid, ignored -> new ArrayList<>());
int desiredCount = cappedFreeBotCount();
if (bots.size() >= desiredCount) {
return;
}
Set<String> reservedNames = allBotNames();
for (int id = bots.size() + 1; id <= desiredCount; id++) {
String botName = buildUniqueBotName(ownerUuid, ownerName, id, reservedNames);
bots.add(botName);
CbotMod.LOGGER.info("Created free bot for {} ({}) -> {}", ownerName, ownerUuid, botName);
}
markDirty();
}
public OwnedBot buyBot(UUID ownerUuid, String ownerName) {
rememberOwnerName(ownerUuid, ownerName);
List<String> bots = ownedBots.computeIfAbsent(ownerUuid, ignored -> new ArrayList<>());
int nextId = bots.size() + 1;
String botName = buildUniqueBotName(ownerUuid, ownerName, nextId, allBotNames());
bots.add(botName);
markDirty();
return new OwnedBot(nextId, botName);
}
public boolean canBuyBot(UUID ownerUuid) {
if (botCap == UNLIMITED_BOT_CAP) {
return true;
}
return getBots(ownerUuid).size() < botCap;
}
public int getBotCap() {
return botCap;
}
public String getBotCapDisplay() {
return botCap == UNLIMITED_BOT_CAP ? "none" : Integer.toString(botCap);
}
public String getBotNamePattern() {
return botNamePattern;
}
public int getStartingFreeBots() {
return startingFreeBots;
}
public String getPriceItemId() {
return priceItemId;
}
public int getPriceCount() {
return priceCount;
}
public void setStartingFreeBots(int startingFreeBots) {
this.startingFreeBots = Math.max(0, startingFreeBots);
markDirty();
}
public void setPrice(String priceItemId, int priceCount) {
this.priceItemId = priceItemId;
this.priceCount = Math.max(0, priceCount);
markDirty();
}
public void setBotCap(int botCap) {
this.botCap = botCap < 0 ? UNLIMITED_BOT_CAP : botCap;
markDirty();
}
public int setBotNamePatternAndRename(String botNamePattern) {
this.botNamePattern = botNamePattern.isBlank() ? DEFAULT_BOT_NAME_PATTERN : botNamePattern;
int renamed = renameAllBots();
markDirty();
return renamed;
}
public int resetConfigAndRename() {
botNamePattern = DEFAULT_BOT_NAME_PATTERN;
startingFreeBots = DEFAULT_STARTING_FREE_BOTS;
priceItemId = DEFAULT_PRICE_ITEM_ID;
priceCount = DEFAULT_PRICE_COUNT;
botCap = UNLIMITED_BOT_CAP;
int renamed = renameAllBots();
markDirty();
return renamed;
}
private int renameAllBots() {
Set<String> reservedNames = new HashSet<>();
int renamed = 0;
List<UUID> owners = ownedBots.keySet().stream()
.sorted(Comparator.comparing(UUID::toString))
.toList();
for (UUID ownerUuid : owners) {
List<String> oldBots = ownedBots.get(ownerUuid);
if (oldBots == null || oldBots.isEmpty()) {
continue;
}
String ownerName = ownerNames.getOrDefault(ownerUuid, inferOwnerName(oldBots));
if (ownerName.isBlank()) {
ownerName = ownerUuid.toString().substring(0, 8);
}
ownerNames.put(ownerUuid, ownerName);
List<String> newBots = new ArrayList<>();
for (int index = 0; index < oldBots.size(); index++) {
String newName = buildUniqueBotName(ownerUuid, ownerName, index + 1, reservedNames);
if (!newName.equals(oldBots.get(index))) {
renamed++;
}
newBots.add(newName);
}
ownedBots.put(ownerUuid, newBots);
}
return renamed;
}
private void rememberOwnerName(UUID ownerUuid, String ownerName) {
if (!ownerName.isBlank() && !ownerName.equals(ownerNames.get(ownerUuid))) {
ownerNames.put(ownerUuid, ownerName);
markDirty();
}
}
private int cappedFreeBotCount() {
if (botCap == UNLIMITED_BOT_CAP) {
return startingFreeBots;
}
return Math.min(startingFreeBots, botCap);
}
private Set<String> allBotNames() {
Set<String> names = new HashSet<>();
for (List<String> bots : ownedBots.values()) {
names.addAll(bots);
}
return names;
}
private String buildUniqueBotName(UUID ownerUuid, String ownerName, int id, Set<String> reservedNames) {
String rendered = renderBotName(ownerUuid, ownerName, id);
String name = fitBotName(rendered, ownerUuid, id, 0);
int salt = 1;
while (reservedNames.contains(name)) {
name = fitBotName(rendered, ownerUuid, id, salt++);
}
reservedNames.add(name);
return name;
}
private String renderBotName(UUID ownerUuid, String ownerName, int id) {
String rendered = botNamePattern
.replace("{player}", ownerName)
.replace("{id}", Integer.toString(id))
.replace("{uuid}", ownerUuid.toString());
rendered = UNSAFE_NAME_CHARS.matcher(rendered).replaceAll("_");
if (rendered.isBlank()) {
rendered = "cbot" + id;
}
return rendered;
}
private static String fitBotName(String rendered, UUID ownerUuid, int id, int salt) {
if (rendered.length() <= MAX_BOT_NAME_LENGTH && salt == 0) {
return rendered;
}
String hash = shortHash(ownerUuid, id, salt, rendered);
int prefixLength = MAX_BOT_NAME_LENGTH - hash.length() - 1;
String prefix = rendered.length() > prefixLength ? rendered.substring(0, prefixLength) : rendered;
return prefix + "_" + hash;
}
private static String shortHash(UUID ownerUuid, int id, int salt, String rendered) {
String hash = Integer.toUnsignedString(Objects.hash(ownerUuid, id, salt, rendered), 36);
if (hash.length() >= 5) {
return hash.substring(0, 5);
}
return ("00000" + hash).substring(hash.length());
}
private static String inferOwnerName(List<String> bots) {
if (bots.isEmpty()) {
return "";
}
Matcher matcher = OLD_DEFAULT_NAME.matcher(bots.getFirst());
if (matcher.matches()) {
return matcher.group(1);
}
return "";
}
public record OwnedBot(int id, String name) {}
private record OwnerData(String uuid, String ownerName, List<String> bots) {
private static final Codec<OwnerData> CODEC = RecordCodecBuilder.create(instance ->
instance.group(
Codec.STRING.fieldOf("uuid").forGetter(OwnerData::uuid),
Codec.STRING.optionalFieldOf("ownerName", "").forGetter(OwnerData::ownerName),
Codec.STRING.listOf().fieldOf("bots").forGetter(OwnerData::bots)
).apply(instance, OwnerData::new)
);
}
private record ConfigData(String botNamePattern, int startingFreeBots, String priceItemId, int priceCount, int botCap) {
private static final ConfigData DEFAULT = new ConfigData(
DEFAULT_BOT_NAME_PATTERN,
DEFAULT_STARTING_FREE_BOTS,
DEFAULT_PRICE_ITEM_ID,
DEFAULT_PRICE_COUNT,
UNLIMITED_BOT_CAP
);
private static final Codec<ConfigData> CODEC = RecordCodecBuilder.create(instance ->
instance.group(
Codec.STRING.optionalFieldOf("botNamePattern", DEFAULT_BOT_NAME_PATTERN).forGetter(ConfigData::botNamePattern),
Codec.INT.optionalFieldOf("startingFreeBots", DEFAULT_STARTING_FREE_BOTS).forGetter(ConfigData::startingFreeBots),
Codec.STRING.optionalFieldOf("priceItem", DEFAULT_PRICE_ITEM_ID).forGetter(ConfigData::priceItemId),
Codec.INT.optionalFieldOf("priceCount", DEFAULT_PRICE_COUNT).forGetter(ConfigData::priceCount),
Codec.INT.optionalFieldOf("botCap", UNLIMITED_BOT_CAP).forGetter(ConfigData::botCap)
).apply(instance, ConfigData::new)
);
}
}

View file

@ -0,0 +1,18 @@
package com.feror.cbot.util;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.command.permission.PermissionPredicate;
import net.minecraft.server.command.ServerCommandSource;
public final class CommandDispatchUtil {
private CommandDispatchUtil() {}
public static int executeAsServer(ServerCommandSource baseSource, String command) {
ServerCommandSource elevatedSource = baseSource.withPermissions(PermissionPredicate.ALL);
try {
return baseSource.getServer().getCommandManager().getDispatcher().execute(command, elevatedSource);
} catch (CommandSyntaxException exception) {
return 0;
}
}
}

View file

@ -0,0 +1,60 @@
package com.feror.cbot.util;
import net.minecraft.entity.player.PlayerInventory;
import net.minecraft.item.Item;
import net.minecraft.item.ItemStack;
import net.minecraft.server.network.ServerPlayerEntity;
public final class InventoryUtil {
private InventoryUtil() {}
public static boolean consumeOne(ServerPlayerEntity player, Item item) {
return consume(player, item, 1);
}
public static boolean consume(ServerPlayerEntity player, Item item, int count) {
if (count <= 0) {
return true;
}
if (count(player, item) < count) {
return false;
}
PlayerInventory inventory = player.getInventory();
int remaining = count;
for (int i = 0; i < inventory.size(); i++) {
ItemStack stack = inventory.getStack(i);
if (!stack.isOf(item) || stack.isEmpty()) {
continue;
}
int consumed = Math.min(remaining, stack.getCount());
stack.decrement(consumed);
remaining -= consumed;
if (remaining == 0) {
inventory.markDirty();
player.currentScreenHandler.sendContentUpdates();
return true;
}
}
return false;
}
public static int count(ServerPlayerEntity player, Item item) {
PlayerInventory inventory = player.getInventory();
int total = 0;
for (int i = 0; i < inventory.size(); i++) {
ItemStack stack = inventory.getStack(i);
if (!stack.isOf(item) || stack.isEmpty()) {
continue;
}
total += stack.getCount();
}
return total;
}
}

View file

@ -0,0 +1,4 @@
{
"key.categories.cbot": "Carpet Bot Manager",
"key.cbot.open_gui": "Open Carpet Bot Manager"
}

View file

@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"id": "cbot",
"version": "${version}",
"name": "Carpet Bot Manager",
"description": "Player-owned bot management layer on top of Fabric Carpet /player",
"authors": [
"Feror"
],
"contact": {},
"license": "MIT",
"environment": "*",
"entrypoints": {
"main": [
"com.feror.cbot.CbotMod"
],
"client": [
"com.feror.cbot.client.CbotClientMod"
]
},
"depends": {
"fabricloader": ">=0.16.0",
"fabric-api": "*",
"minecraft": ">=1.21.11"
},
"suggests": {
"carpet": "*"
}
}