Initial minecraft-builder-mcp prototype and Gothic hall build

This commit is contained in:
Emil
2026-09-12 23:22:20 +03:00
commit 6682668c16
103 changed files with 10064 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.gradle/
build/
run/
.idea/
*.iml
+100
View File
@@ -0,0 +1,100 @@
# Minecraft Builder Camera
Клиентский Fabric-мод для `minecraft-builder-mcp`. Предоставляет настоящий PNG из framebuffer Minecraft через защищённый локальный HTTP-интерфейс. Отрисовка требует запущенного клиента с рабочим графическим окружением. Мод не входит в серверный JAR и не изменяет блоки.
## Закреплённая платформа
- Minecraft Java Edition **26.2**, Java **25**.
- Fabric Loader **0.19.5**, Fabric API **0.160.0+26.2**.
- Fabric Loom **1.17.20**, Gradle Wrapper **9.5.1** (SHA-256 дистрибутива проверяется).
- JUnit **5.12.2** используется только при сборке тестов.
Версии проверены по [Fabric Maven](https://maven.fabricmc.net/), [Fabric Meta](https://meta.fabricmc.net/v2/versions/loader/26.2) и [официальному примеру 26.2](https://github.com/FabricMC/fabric-example-mod/tree/26.2). Начиная с 26.1 Minecraft не обфусцирован; Yarn и перепривязка имён для этой сборки не нужны. [Инструкция Fabric для 26.2](https://www.fabricmc.net/2026/06/15/262.html).
## Сборка и установка
```bash
cd camera-mod
JAVA_HOME=/path/to/jdk-25 ./gradlew build
```
Результат: `build/libs/minecraft-builder-camera-0.1.0-SNAPSHOT.jar`. Установить его и закреплённый Fabric API в отдельный профиль Minecraft 26.2 с Fabric Loader. Клиент обычного строителя не требует этого мода.
Перед запуском профиля задать окружение процесса:
```bash
export MCB_CAMERA_TOKEN='<отдельный секрет длиной не менее 32 символов>'
export MCB_CAMERA_PORT=8766
```
Тот же секрет указать в конфигурации Paper-плагина для подключения камеры. Paper принимает 32–512 символов из `A–Z`, `a–z`, `0–9`, `.`, `_`, `~`, `-`, без пробелов и переносов; автоматически созданное значение уже подходит. Все три ключа Paper должны различаться. Без `MCB_CAMERA_TOKEN` HTTP-служба отключена. `MCB_CAMERA_PORT` необязателен; допустимы порты 1024–65535. Адрес всегда `127.0.0.1`, переключения на публичный интерфейс нет.
Запустить отдельного наблюдателя, подключиться к нужному Paper-серверу и перевести его в spectator разрешённым серверным способом. Указать UUID наблюдателя в Paper-плагине. Нужна допустимая отдельная игровая сессия, если строитель остаётся на сервере одновременно; мод не обходит вход или ограничения аккаунтов. Держать клиент с закрытыми меню, без слежения за другой сущностью. Свёрнутое окно может прекратить рендеринг и вызвать таймаут.
### Один клиент через Prism
Для локального теста достаточно одной учётной записи: владелец проекта одновременно служит камерой. Этот вариант проверен на настоящем клиенте Prism с Paper 26.2. В выбранный профиль установить мод и зависимости, подключиться к серверу и привязать владельца через `/ai setup`. В приватном конфиге Paper `camera-player-uuid` должен совпадать с `owner-uuid`. Владелец должен быть онлайн, иметь разрешение `minecraftbuilder.use` и находиться в spectator. Изменения конфигурации применяются после перезапуска плагина/сервера.
Чтобы секрет не попадал в аргументы Java и журнал лаунчера, использовать [scripts/camera-wrapper.py](../scripts/camera-wrapper.py) как `WrapperCommand` профиля Prism, например `python3 /path/to/minecraft-builder-mcp/scripts/camera-wrapper.py`. Обёртка читает `camera-token` и `camera-port` из приватного `.runtime/server/plugins/MinecraftBuilderMCP/config.yml` и передаёт их только через окружение дочернего процесса. Другой путь к конфигу задаётся переменной `MCB_CAMERA_PAPER_CONFIG`.
Сохранить ракурс внутри области проекта командой `/ai camera save test`. Затем из корня репозитория запустить:
```bash
python3 scripts/live-camera-test.py --camera-id test --delay 8
```
[scripts/live-camera-test.py](../scripts/live-camera-test.py) проверяет совпадение UUID владельца и камеры, spectator и доступ к Paper. Через восемь секунд он вызывает настоящий `camera_capture` по авторизованному HTTP-маршруту Paper, ждёт PNG и сохраняет исходные байты вместе с очищенными метаданными в `.runtime/camera-test`. Вместо сохранённого ракурса можно передать `--pose X Y Z YAW PITCH`, дополнив его `--fov 85` для общего вида большой постройки (допустимо 30–110°); `--after-operation-id` связывает снимок с завершённой операцией.
До начала съёмки вернуться в окно Minecraft, закрыть чат и меню, остановиться и не двигать мышь. Допустимое изменение поворота всего 0.1°, поэтому даже небольшой сдвиг отменяет кадр. Переключение в другое окно может открыть меню паузы. В этом режиме снимок временно использует твой игровой вид; серверная телепортация меняет твою позицию. Режим игры и прежняя позиция автоматически не восстанавливаются. Для возврата к строительству выбрать нужный режим и место вручную.
## Протокол
Все запросы, включая health и чтение изображения, требуют `Authorization: Bearer <MCB_CAMERA_TOKEN>`. JSON не записывается в лог.
- `GET /health` — кэш состояния последнего клиентского тика: `status`, `connected`, `spectator`, `busy`, `dimension`, `playerId`, `updatedAt`. Старое `updatedAt` означает, что клиент перестал обновляться.
- `POST /v1/capture` — поставить один снимок в работу. Ответ HTTP 202: `{"status":"pending","captureId":"<uuid>"}`. При занятой камере HTTP 409 и `camera_busy`.
- `GET /v1/captures/<uuid>` — получить `pending`, `completed` либо `error`. Неизвестный/истёкший ID: HTTP 404. Терминальный `error` имеет `error` и `message`, без изображения.
Пример тела capture:
```json
{
"x": 16.5, "y": 90, "z": 16.5,
"yaw": 45, "pitch": 25,
"fov": 70, "width": 1280, "height": 720,
"dimension": "minecraft:overworld",
"afterOperationId": "operation-id"
}
```
`x/y/z` — позиция **ног игрока-наблюдателя**, как в Paper teleport. Paper сначала проверяет область/права и телепортирует настроенного наблюдателя; затем вызывает capture. Мод ждёт получения нужной позиции и измерения, но сам не отправляет `/tp` и не подменяет локальную позицию. `dimension` — клиентский ключ измерения, не имя папки и не Bukkit UUID. Дополнительные `world`/`world_id` принимаются как совместимые поля конверта, но не используются как доказательство измерения. `dimension` необязателен в низкоуровневом интерфейсе; серверный маршрут должен передавать его.
Серверный маршрут Paper `camera_capture` передаёт POST, а при наличии `capture_id` опрашивает соответствующий GET. Конкретные названия внешних MCP-инструментов определяет Bridge.
Результат `completed` содержит `imageBase64`, `mimeType: "image/png"`, `captureId`, `capturedAt`, `dimension`, позицию ног, `eyeY`, фактические yaw/pitch, базовый FOV, размеры исходного framebuffer и изображения, а также метаданные готовности.
`width`/`height` задают максимальные размеры выходного изображения. Снимок вписывается в них с сохранением пропорций и без увеличения; разрешение окна не меняется. Это предотвращает искажение геометрии. Для точных 1280×720 следует использовать framebuffer такого же соотношения сторон и достаточного размера. Базовый FOV ограничен 30–110, ширина 320–1920, высота 180–1080; исходный framebuffer ограничен 16 мегапикселями. Поза требует конечных чисел, yaw -360..360 и pitch -90..90.
## Что означает готовность
Перед снимком проверяются spectator, совпадение позиции (±0.05 блока), измерения и собственного вида наблюдателя. Мод скрывает HUD, включает первый вид, отключает покачивание и влияние движения на FOV. Затем ждёт:
1. Девять клиентских чанков вокруг наблюдателя доступны не менее 20 тиков подряд.
2. В течение трёх кадров камера инициализирована, чанки доступны, очередь подготовки геометрии пуста.
3. Поза и окно остаются подходящими до чтения framebuffer.
PNG снимается через `Screenshot.takeScreenshot` после рендера кадра, с GPU readback через Blaze3D; прямого OpenGL-кода нет. Кодирование и уменьшение PNG выполняются отдельным потоком. HUD, FOV, перспектива, покачивание и поворот, сохранённые при начале работы мода с кадром, восстанавливаются на клиентском потоке после завершения или ошибки. Сохранение начинается после получения серверной позиции; это не возврат к положению игрока до телепортации. Позиция после серверной телепортации остаётся серверной.
Это **проверяемая эвристика загрузки**, а не подтверждение конкретной серверной ревизии. Ответ всегда содержит `readiness: "local_chunks_and_render_queue_stable"` и `serverRevisionVerified: false`. Поле `afterOperationId` служит корреляцией; само по себе оно не доказывает, что клиент получил все обновления операции. Нельзя выдавать такой результат за проверку ревизии. Для строгой свежести нужен дополнительный серверный маркер и подтверждение обработки соответствующих пакетов. Дальняя геометрия вне проверенных чанков и изменения после кадра остаются ограничениями.
Ошибки загрузки, отключение, смена мира/позиции, открытые меню, вмешательство в поворот и неполученный framebuffer возвращают ошибку вместо старого кадра. Таймаут 20 секунд контролируется отдельным потоком даже при зависшем рендере. Следующий снимок разрешается после восстановления состояния на клиентском потоке. Хранятся максимум четыре результата не дольше двух минут; PNG до 8 MiB, тело запроса до 8192 байт. Изображения находятся в памяти и не записываются в общий каталог screenshots.
## Проверки и границы прототипа
`./gradlew build` компилирует мод против настоящих зависимостей Minecraft 26.2; тесты проверяют bearer-аутентификацию HTTP, ограничение размера запроса и валидацию параметров. Для них не запускаются клиент или вход в аккаунт.
12 сентября 2026 года выполнен реальный графический тест: один клиент Prism, владелец проекта в spectator, одинаковый UUID владельца и камеры, Paper 26.2 и построенная башня из 575 блоков. Проверены загрузка Mixin, подключение клиента, серверная телепортация, чтение framebuffer и доставка PNG через Paper HTTP. На изображении видна построенная башня без HUD.
Первый запрос завершился `view_changed`: фактический поворот отличался от заданного. Повтор после стабилизации дал PNG **1280×720 за 2.052 секунды**, с yaw **140°**, pitch **31°**, после **20 тиков** и **3 кадров** готовности. Это подтверждённый локальный замер одного запроса, а не гарантия времени для других сцен и компьютеров. Артефакты проверки: `.runtime/camera-test/20260912T192813Z-2b100930.png` и соответствующий JSON; они остаются локальными и не входят в Git.
Успешный кадр получен после завершения строительной операции и содержит её `afterOperationId`, но **`serverRevisionVerified` остаётся `false`**: подтверждения обработки конкретной серверной ревизии ещё нет. Отдельно остаются проверки восстановления всех настроек вида, таймаута при свёрнутом окне, отключения посреди снимка, сторонних шейдеров и отдельного аккаунта камеры. Рабочий графический цикл подтверждён для описанного сценария с одним клиентом.
+37
View File
@@ -0,0 +1,37 @@
plugins {
id 'net.fabricmc.fabric-loom' version "${loom_version}"
}
repositories { mavenCentral() }
loom {
splitEnvironmentSourceSets()
mods {
'minecraft_builder_camera' {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
testImplementation platform('org.junit:junit-bom:5.12.2')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') { expand version: project.version }
}
tasks.withType(JavaCompile).configureEach { options.release = 25 }
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
test { useJUnitPlatform() }
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
sourceSets.test.compileClasspath += sourceSets.client.output
sourceSets.test.runtimeClasspath += sourceSets.client.output
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=false
org.gradle.configuration-cache=false
minecraft_version=26.2
loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.2
version=0.1.0-SNAPSHOT
group=dev.minecraftbuilder
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
networkTimeout=30000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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='-Dfile.encoding=UTF-8 "-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" "$@"
+82
View File
@@ -0,0 +1,82 @@
@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, and ensure extensions are enabled
setlocal EnableExtensions
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=-Dfile.encoding=UTF-8 "-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
"%COMSPEC%" /c exit 1
: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
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+8
View File
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
maven { url = 'https://maven.fabricmc.net/' }
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'minecraft-builder-camera'
@@ -0,0 +1,300 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import com.mojang.blaze3d.platform.NativeImage;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Screenshot;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.level.chunk.status.ChunkStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** One observer, one active request, bounded retained screenshots. No world writes or client teleport hacks. */
public final class CameraClient implements ClientModInitializer, CameraHttpServer.Backend {
private static final Logger LOGGER = LoggerFactory.getLogger("minecraft-builder-camera");
private static final long TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(20);
private static volatile CameraClient instance;
private final AtomicReference<Job> active = new AtomicReference<>();
private final LinkedHashMap<String, Job> jobs = new LinkedHashMap<>();
private final ExecutorService encoder = Executors.newSingleThreadExecutor(Thread.ofPlatform()
.daemon(true).name("mcb-camera-encoder").factory());
private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform()
.daemon(true).name("mcb-camera-watchdog").factory());
private volatile JsonObject cachedHealth = CameraHttpServer.error("starting", "Waiting for client tick");
private CameraHttpServer http;
@Override public void onInitializeClient() {
String token = System.getenv("MCB_CAMERA_TOKEN");
if (token == null || token.isBlank()) {
LOGGER.info("Camera HTTP disabled: set MCB_CAMERA_TOKEN to enable the dedicated observer service");
encoder.shutdownNow();
watchdog.shutdownNow();
return;
}
try {
int port = Integer.parseInt(System.getenv().getOrDefault("MCB_CAMERA_PORT", "8766"));
if (port < 1024 || port > 65535) throw new IllegalArgumentException("Invalid camera port");
http = new CameraHttpServer(port, token, this);
instance = this;
ClientTickEvents.END_CLIENT_TICK.register(this::tick);
ClientLifecycleEvents.CLIENT_STOPPING.register(this::stop);
watchdog.scheduleAtFixedRate(this::expire, 1, 1, TimeUnit.SECONDS);
http.start();
LOGGER.info("Camera HTTP listening on 127.0.0.1:{} (authenticated)", port);
} catch (Exception exception) {
LOGGER.error("Camera service could not start: {}", exception.getClass().getSimpleName());
encoder.shutdownNow();
watchdog.shutdownNow();
}
}
@Override public JsonObject health() { return cachedHealth.deepCopy(); }
@Override public synchronized JsonObject submit(CaptureRequest request) {
Job job = new Job(request);
if (!active.compareAndSet(null, job)) return CameraHttpServer.error("camera_busy", "One capture is already active");
jobs.put(job.id, job);
while (jobs.size() > 4) jobs.remove(jobs.keySet().iterator().next());
return job.result.deepCopy();
}
@Override public synchronized JsonObject poll(String captureId) {
Job job = jobs.get(captureId);
return job == null ? null : job.result.deepCopy();
}
private synchronized void expire() {
Job job = active.get();
if (job != null && System.nanoTime() - job.createdNanos >= TIMEOUT_NANOS)
job.fail("capture_timeout", "Scene did not become ready within 20 seconds; no fresh image returned");
// Keep an expired active job until a client tick can restore its view safely.
jobs.values().removeIf(value -> value != active.get()
&& System.nanoTime() - value.createdNanos > TimeUnit.MINUTES.toNanos(2));
}
private void tick(Minecraft client) {
Job job = active.get();
JsonObject health = new JsonObject();
health.addProperty("status", "ok");
health.addProperty("connected", client.level != null && client.player != null);
health.addProperty("spectator", client.player != null && client.player.isSpectator());
health.addProperty("busy", job != null);
health.addProperty("updatedAt", Instant.now().toString());
if (client.level != null) health.addProperty("dimension", dimension(client));
if (client.player != null) health.addProperty("playerId", client.player.getUUID().toString());
cachedHealth = health;
if (job == null) return;
if (job.done) { restore(client, job); active.compareAndSet(job, null); return; }
if (client.player == null || client.level == null) { job.fail("disconnected", "Camera client is not in a world"); return; }
if (!client.player.isSpectator()) { job.fail("spectator_required", "Camera account must be in spectator mode"); return; }
if (client.gui.screen() != null || client.gui.overlay() != null || client.isPaused()) {
job.fail("view_obstructed", "Close menus and overlays in the observer client"); return;
}
if (!job.initialized) {
// Paper owns teleports. Wait for its position/dimension packet before adjusting the view.
if (!matchesPosition(client, job.request)) return;
job.player = client.player;
job.level = client.level;
job.saved = new SavedView(client.gui.hud.isHidden(), client.options.fov().get(),
client.options.bobView().get(), client.options.fovEffectScale().get(),
client.options.getCameraType(), client.player.getYRot(), client.player.getXRot());
if (!client.gui.hud.isHidden()) client.gui.hud.toggle();
client.options.fov().set(job.request.fov());
client.options.bobView().set(false);
client.options.fovEffectScale().set(0.0);
client.options.setCameraType(CameraType.FIRST_PERSON);
client.player.setYRot(job.request.yaw());
client.player.setXRot(job.request.pitch());
client.player.setOldRot();
job.initialized = true;
}
if (client.player != job.player || client.level != job.level || !matchesPosition(client, job.request)) {
job.fail("camera_moved", "Observer moved or changed world during capture"); return;
}
if (client.getCameraEntity() != client.player) {
job.fail("spectating_entity", "Observer must use its own camera, not another entity"); return;
}
if (!matchesView(client, job.request)) { job.fail("view_changed", "Observer view changed during capture"); return; }
if (chunksLoaded(client)) job.stableTicks++; else { job.stableTicks = 0; job.readyFrames = 0; }
}
/** Called after GameRenderer.render, on Minecraft's render thread. Uses the supported GPU screenshot API. */
public static void afterRender(boolean renderWorld) {
CameraClient worker = instance;
if (worker != null && renderWorld) worker.rendered(Minecraft.getInstance());
}
private void rendered(Minecraft client) {
Job job = active.get();
if (job == null || job.done || !job.initialized || job.readbackStarted || job.stableTicks < 20) return;
if (client.player != job.player || client.level != job.level || client.gui.screen() != null
|| client.gui.overlay() != null || !matchesPosition(client, job.request) || !matchesView(client, job.request)) {
job.fail("view_changed", "Observer view changed before frame capture"); return;
}
if (!client.gameRenderer.mainCamera().isInitialized() || !chunksLoaded(client)
|| client.levelRenderer.sectionRenderDispatcher() == null || !client.levelRenderer.hasRenderedAllSections()) {
job.readyFrames = 0; return;
}
if (++job.readyFrames < 3) return;
var target = client.gameRenderer.mainRenderTarget();
if (target.width <= 0 || target.height <= 0 || (long) target.width * target.height > 16_777_216) {
job.fail("framebuffer_size", "Observer framebuffer is empty or exceeds 16 megapixels"); return;
}
job.readbackStarted = true;
JsonObject metadata = new JsonObject();
metadata.addProperty("capturedAt", Instant.now().toString());
metadata.addProperty("dimension", dimension(client));
metadata.addProperty("x", client.player.getX());
metadata.addProperty("y", client.player.getY());
metadata.addProperty("z", client.player.getZ());
metadata.addProperty("eyeY", client.gameRenderer.mainCamera().position().y);
metadata.addProperty("yaw", client.gameRenderer.mainCamera().yRot());
metadata.addProperty("pitch", client.gameRenderer.mainCamera().xRot());
metadata.addProperty("fov", job.request.fov());
metadata.addProperty("readiness", "local_chunks_and_render_queue_stable");
metadata.addProperty("serverRevisionVerified", false);
metadata.addProperty("loadedChunkRadius", 1);
metadata.addProperty("stabilizationTicks", job.stableTicks);
metadata.addProperty("stabilizationFrames", job.readyFrames);
if (job.request.afterOperationId() != null) metadata.addProperty("afterOperationId", job.request.afterOperationId());
try {
Screenshot.takeScreenshot(target, image -> {
if (job.done || encoder.isShutdown()) { image.close(); return; }
try { encoder.execute(() -> encode(job, image, metadata)); }
catch (RuntimeException error) { image.close(); job.fail("encoding_unavailable", "Image encoder unavailable"); }
});
} catch (Exception exception) {
job.fail("readback_failed", "Could not read observer framebuffer");
}
}
private static void encode(Job job, NativeImage image, JsonObject metadata) {
try (image; ByteArrayOutputStream bytes = new ByteArrayOutputStream()) {
if (job.done) return;
int sourceWidth = image.getWidth(), sourceHeight = image.getHeight();
double scale = Math.min(1, Math.min((double) job.request.width() / sourceWidth,
(double) job.request.height() / sourceHeight));
int width = Math.max(1, (int) Math.round(sourceWidth * scale));
int height = Math.max(1, (int) Math.round(sourceHeight * scale));
BufferedImage source = new BufferedImage(sourceWidth, sourceHeight, BufferedImage.TYPE_INT_ARGB);
source.setRGB(0, 0, sourceWidth, sourceHeight, image.getPixels(), 0, sourceWidth);
BufferedImage output = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = output.createGraphics();
try {
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
graphics.drawImage(source, 0, 0, width, height, null);
} finally { graphics.dispose(); source.flush(); }
if (!ImageIO.write(output, "png", bytes)) throw new IllegalStateException("PNG writer unavailable");
output.flush();
if (bytes.size() > 8 * 1024 * 1024) { job.fail("image_too_large", "PNG exceeds 8 MiB limit"); return; }
JsonObject result = metadata.deepCopy();
result.addProperty("status", "completed");
result.addProperty("mimeType", "image/png");
result.addProperty("width", width);
result.addProperty("height", height);
result.addProperty("sourceWidth", sourceWidth);
result.addProperty("sourceHeight", sourceHeight);
result.addProperty("imageBase64", Base64.getEncoder().encodeToString(bytes.toByteArray()));
job.finish(result);
} catch (Exception exception) { job.fail("encoding_failed", "Could not encode observer screenshot"); }
}
private static String dimension(Minecraft client) { return client.level.dimension().identifier().toString(); }
private static boolean matchesPosition(Minecraft client, CaptureRequest request) {
return client.player != null && client.level != null
&& (request.dimension() == null || request.dimension().equals(dimension(client)))
&& Math.abs(client.player.getX() - request.x()) <= 0.05
&& Math.abs(client.player.getY() - request.y()) <= 0.05
&& Math.abs(client.player.getZ() - request.z()) <= 0.05;
}
private static boolean matchesView(Minecraft client, CaptureRequest request) {
return client.gui.hud.isHidden() && client.options.getCameraType() == CameraType.FIRST_PERSON
&& client.options.fov().get() == request.fov()
&& Math.abs(Math.IEEEremainder(client.player.getYRot() - request.yaw(), 360)) <= 0.1
&& Math.abs(client.player.getXRot() - request.pitch()) <= 0.1;
}
private static boolean chunksLoaded(Minecraft client) {
int cx = Math.floorDiv(client.player.blockPosition().getX(), 16);
int cz = Math.floorDiv(client.player.blockPosition().getZ(), 16);
for (int dx = -1; dx <= 1; dx++) for (int dz = -1; dz <= 1; dz++)
if (client.level.getChunkSource().getChunk(cx + dx, cz + dz, ChunkStatus.FULL, false) == null) return false;
return true;
}
private static void restore(Minecraft client, Job job) {
SavedView saved = job.saved;
if (saved == null) return;
if (client.gui.hud.isHidden() != saved.hudHidden) client.gui.hud.toggle();
client.options.fov().set(saved.fov);
client.options.bobView().set(saved.bobView);
client.options.fovEffectScale().set(saved.fovEffectScale);
client.options.setCameraType(saved.cameraType);
if (client.player == job.player) {
client.player.setYRot(saved.yaw);
client.player.setXRot(saved.pitch);
client.player.setOldRot();
}
job.saved = null;
}
private void stop(Minecraft client) {
instance = null;
Job job = active.getAndSet(null);
if (job != null) { job.fail("client_stopping", "Camera client is stopping"); restore(client, job); }
if (http != null) http.close();
watchdog.shutdownNow();
encoder.shutdown();
}
private record SavedView(boolean hudHidden, int fov, boolean bobView, double fovEffectScale,
CameraType cameraType, float yaw, float pitch) {}
private static final class Job {
final String id = UUID.randomUUID().toString();
final CaptureRequest request;
final long createdNanos = System.nanoTime();
volatile JsonObject result;
volatile boolean done;
boolean initialized, readbackStarted;
int stableTicks, readyFrames;
SavedView saved;
LocalPlayer player;
ClientLevel level;
Job(CaptureRequest request) {
this.request = request;
result = new JsonObject();
result.addProperty("status", "pending");
result.addProperty("captureId", id);
}
synchronized void finish(JsonObject value) {
if (done) return;
value.addProperty("captureId", id);
result = value;
done = true;
}
void fail(String code, String message) { finish(CameraHttpServer.error(code, message)); }
}
}
@@ -0,0 +1,98 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** No Minecraft APIs are called by HTTP threads. Backend only queues work and reads cached results. */
public final class CameraHttpServer implements AutoCloseable {
public interface Backend {
JsonObject health();
JsonObject submit(CaptureRequest request);
JsonObject poll(String captureId);
}
private final HttpServer server;
private final ExecutorService executor = Executors.newFixedThreadPool(2, Thread.ofPlatform()
.daemon(true).name("mcb-camera-http-", 0).factory());
private final byte[] authorization;
private final Backend backend;
public CameraHttpServer(int port, String token, Backend backend) throws IOException {
if (token == null || token.length() < 32 || token.length() > 512 || token.chars().anyMatch(Character::isWhitespace))
throw new IllegalArgumentException("MCB_CAMERA_TOKEN must contain 32..512 non-whitespace characters");
this.authorization = ("Bearer " + token).getBytes(StandardCharsets.UTF_8);
this.backend = backend;
server = HttpServer.create(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port), 8);
server.setExecutor(executor);
server.createContext("/", this::handle);
}
public void start() { server.start(); }
public int port() { return server.getAddress().getPort(); }
private void handle(HttpExchange exchange) throws IOException {
try (exchange) {
String bearer = exchange.getRequestHeaders().getFirst("Authorization");
if (bearer == null || !MessageDigest.isEqual(authorization, bearer.getBytes(StandardCharsets.UTF_8))) {
send(exchange, 401, error("unauthorized", "Valid camera bearer token required"));
return;
}
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
if (path.equals("/health") && method.equals("GET")) {
send(exchange, 200, backend.health());
} else if (path.equals("/v1/capture") && method.equals("POST")) {
byte[] body = exchange.getRequestBody().readNBytes(8193);
if (body.length > 8192) {
send(exchange, 413, error("request_too_large", "Capture request exceeds 8192 bytes"));
return;
}
try {
JsonObject result = backend.submit(CaptureRequest.parse(JsonParser.parseString(
new String(body, StandardCharsets.UTF_8)).getAsJsonObject()));
send(exchange, result.has("error") ? 409 : 202, result);
} catch (RuntimeException exception) {
send(exchange, 400, error("invalid_request", "Invalid capture JSON or capture parameters"));
}
} else if (path.startsWith("/v1/captures/") && method.equals("GET")) {
String id = path.substring("/v1/captures/".length());
if (!id.matches("[0-9a-f-]{36}")) {
send(exchange, 400, error("invalid_capture_id", "Expected a capture UUID"));
return;
}
JsonObject result = backend.poll(id);
send(exchange, result == null ? 404 : 200,
result == null ? error("capture_not_found", "Capture expired or does not exist") : result);
} else {
send(exchange, 404, error("not_found", "Unknown camera endpoint or method"));
}
}
}
public static JsonObject error(String code, String message) {
JsonObject result = new JsonObject();
result.addProperty("status", "error");
result.addProperty("error", code);
result.addProperty("message", message);
return result;
}
private static void send(HttpExchange exchange, int code, JsonObject result) throws IOException {
byte[] bytes = result.toString().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
exchange.sendResponseHeaders(code, bytes.length);
exchange.getResponseBody().write(bytes);
}
@Override public void close() { server.stop(0); executor.shutdownNow(); }
}
@@ -0,0 +1,51 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import java.util.Set;
/** Coordinates are the observer's feet, matching Paper teleports; the reply also gives eye position. */
public record CaptureRequest(double x, double y, double z, float yaw, float pitch, int fov,
int width, int height, String dimension, String afterOperationId) {
private static final Set<String> FIELDS = Set.of("x", "y", "z", "yaw", "pitch", "fov", "width",
"height", "dimension", "world", "world_id", "afterOperationId");
public static CaptureRequest parse(JsonObject body) {
if (!FIELDS.containsAll(body.keySet())) throw new IllegalArgumentException("Unknown capture field");
double x = number(body, "x"), y = number(body, "y"), z = number(body, "z");
double yaw = number(body, "yaw"), pitch = number(body, "pitch");
if (Math.abs(x) > 29_999_984 || Math.abs(z) > 29_999_984 || y < -2048 || y > 2048)
throw new IllegalArgumentException("Position is outside the camera coordinate limits");
if (Math.abs(yaw) > 360 || Math.abs(pitch) > 90)
throw new IllegalArgumentException("yaw must be -360..360 and pitch -90..90");
int fov = integer(body, "fov", 70, 30, 110);
int width = integer(body, "width", 1280, 320, 1920);
int height = integer(body, "height", 720, 180, 1080);
return new CaptureRequest(x, y, z, (float) yaw, (float) pitch, fov, width, height,
string(body, "dimension", 128), string(body, "afterOperationId", 128));
}
private static double number(JsonObject body, String name) {
if (!body.has(name) || !body.get(name).isJsonPrimitive() || !body.getAsJsonPrimitive(name).isNumber())
throw new IllegalArgumentException(name + " must be a number");
double value = body.get(name).getAsDouble();
if (!Double.isFinite(value)) throw new IllegalArgumentException(name + " must be finite");
return value;
}
private static int integer(JsonObject body, String name, int fallback, int min, int max) {
if (!body.has(name)) return fallback;
double value = number(body, name);
if (value != Math.rint(value) || value < min || value > max)
throw new IllegalArgumentException(name + " must be an integer in " + min + ".." + max);
return (int) value;
}
private static String string(JsonObject body, String name, int max) {
if (!body.has(name)) return null;
if (!body.get(name).isJsonPrimitive() || !body.getAsJsonPrimitive(name).isString())
throw new IllegalArgumentException(name + " must be a string");
String value = body.get(name).getAsString();
if (value.isBlank() || value.length() > max) throw new IllegalArgumentException(name + " has invalid length");
return value;
}
}
@@ -0,0 +1,17 @@
package dev.minecraftbuilder.camera.mixin;
import dev.minecraftbuilder.camera.CameraClient;
import net.minecraft.client.DeltaTracker;
import net.minecraft.client.renderer.GameRenderer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(GameRenderer.class)
abstract class GameRendererMixin {
@Inject(method = "render", at = @At("TAIL"))
private void mcb$afterFrame(DeltaTracker deltaTracker, boolean renderWorld, CallbackInfo callback) {
CameraClient.afterRender(renderWorld);
}
}
@@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"id": "minecraft_builder_camera",
"version": "${version}",
"name": "Minecraft Builder Camera",
"description": "Authenticated loopback camera worker for minecraft-builder-mcp.",
"environment": "client",
"entrypoints": { "client": ["dev.minecraftbuilder.camera.CameraClient"] },
"mixins": ["minecraft-builder-camera.mixins.json"],
"depends": {
"fabricloader": ">=0.19.5",
"minecraft": "26.2",
"java": ">=25",
"fabric-api": "0.160.0+26.2"
}
}
@@ -0,0 +1,7 @@
{
"required": true,
"package": "dev.minecraftbuilder.camera.mixin",
"compatibilityLevel": "JAVA_25",
"client": ["GameRendererMixin"],
"injectors": { "defaultRequire": 1 }
}
@@ -0,0 +1,53 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class CameraHttpServerTest {
private static final String TOKEN = "test-camera-token-with-at-least-32-characters";
private static final String VALID = "{\"x\":1,\"y\":64,\"z\":-2,\"yaw\":0,\"pitch\":15}";
@Test void authenticatesBeforeQueueingAndEnforcesRequestBudget() throws Exception {
AtomicInteger queued = new AtomicInteger();
CameraHttpServer.Backend backend = new CameraHttpServer.Backend() {
public JsonObject health() { return JsonParser.parseString("{\"status\":\"ok\"}").getAsJsonObject(); }
public JsonObject submit(CaptureRequest request) {
queued.incrementAndGet();
return JsonParser.parseString("{\"status\":\"pending\",\"captureId\":\"00000000-0000-0000-0000-000000000000\"}").getAsJsonObject();
}
public JsonObject poll(String id) { return null; }
};
try (CameraHttpServer server = new CameraHttpServer(0, TOKEN, backend); HttpClient client = HttpClient.newHttpClient()) {
server.start();
String base = "http://127.0.0.1:" + server.port();
assertEquals(401, send(client, base + "/v1/capture", "POST", VALID, "wrong").statusCode());
assertEquals(0, queued.get());
assertEquals(202, send(client, base + "/v1/capture", "POST", VALID, TOKEN).statusCode());
assertEquals(1, queued.get());
assertEquals(413, send(client, base + "/v1/capture", "POST", " ".repeat(8193), TOKEN).statusCode());
assertEquals(400, send(client, base + "/v1/capture", "POST", "{\"x\":true}", TOKEN).statusCode());
assertEquals(1, queued.get());
assertEquals(404, send(client, base + "/v1/captures/00000000-0000-0000-0000-000000000000", "GET", "", TOKEN).statusCode());
assertEquals("no-store", send(client, base + "/health", "GET", "", TOKEN).headers().firstValue("Cache-Control").orElseThrow());
}
}
@Test void rejectsTokenlessOrWeakService() {
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, null, null));
assertThrows(IllegalArgumentException.class, () -> new CameraHttpServer(0, "weak", null));
}
private static HttpResponse<String> send(HttpClient client, String url, String method, String body, String token) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5))
.header("Authorization", "Bearer " + token).method(method, HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString());
}
}
@@ -0,0 +1,33 @@
package dev.minecraftbuilder.camera;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class CaptureRequestTest {
private static final String POSE = "\"x\":1,\"y\":64,\"z\":-2,\"yaw\":0,\"pitch\":15";
@Test void suppliesBoundedDefaultsAndKeepsCorrelation() {
CaptureRequest request = parse("{" + POSE + ",\"afterOperationId\":\"op-42\",\"dimension\":\"minecraft:overworld\"}");
assertEquals(1280, request.width());
assertEquals(720, request.height());
assertEquals(70, request.fov());
assertEquals("op-42", request.afterOperationId());
}
@ParameterizedTest @ValueSource(strings = {"\"width\":1921", "\"width\":320.5", "\"height\":0",
"\"fov\":111", "\"width\":\"640\"", "\"afterOperationId\":null", "\"shell\":\"noop\""})
void rejectsUnsafeOrAmbiguousParameters(String extra) {
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE + "," + extra + "}"));
}
@Test void rejectsNonFiniteAndOutOfWorldPose() {
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"x\":1", "\"x\":1e999") + "}"));
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"pitch\":15", "\"pitch\":91") + "}"));
assertThrows(IllegalArgumentException.class, () -> parse("{" + POSE.replace("\"y\":64", "\"y\":4096") + "}"));
}
private static CaptureRequest parse(String input) { return CaptureRequest.parse(JsonParser.parseString(input).getAsJsonObject()); }
}